fix(access): verify subscription readiness with real user keys
When subscription access is requested with an explicit access_api_key, assign the subscription to the real target user, bind that user's API key to the subscription group, and probe readiness with the same key instead of falling back to a managed synthetic user. Update the runtime/reconcile flows, adapter tests, and source-of-truth docs so subscription_ready now reflects user-visible host access rather than managed-key-only closure success.
This commit is contained in:
@@ -8,6 +8,37 @@
|
||||
|
||||
- 当前主目录 `artifacts/real-host-acceptance/` 已只保留最终证据;历史调试样本已迁到 `artifacts/real-host-acceptance-archive/`
|
||||
- access ready 语义已经收口为:`/v1/models` 命中 `smoke_test_model`,且最小 `POST /v1/chat/completions` smoke 成功;不会再出现 models-only 假 ready
|
||||
- 2026-06-01 已继续收掉 `subscription_ready` 的最后一个真实闭环缺口:
|
||||
- 根因不是 provider、不是前端,也不是宿主随机波动,而是 CRM 旧实现会在 subscription closure 里把目标用户替换成 synthetic managed user,再用 managed key 做 probe
|
||||
- 这样会出现“closure 返回 `subscription_ready`,但目标用户自己的 `GET /api/v1/subscriptions/active` 仍为空,`/v1/models` 仍然 `403 INSUFFICIENT_BALANCE`”的假阳性
|
||||
- 最新本机真实验收已确认修复后的语义:
|
||||
- 页面级 artifact:`artifacts/provider-admin-matrix/1780271169_provider_admin_actions/99-summary.json`
|
||||
- batch 明细:`GET /api/import-batches/5`
|
||||
- `access_closure.DetailsJSON` 已切成 `effective_probe_key_source=requested_probe_api_key`
|
||||
- 目标用户 `GET /api/v1/subscriptions/active` 已返回 active subscription
|
||||
- 目标用户 `GET /api/v1/groups/available` 已出现 `OpenAI 中转默认分组-subscription`
|
||||
- 目标用户自己的 `local-user-api-key-20260531` 直探 `/v1/models` 与 `/v1/chat/completions` 均已回到 `HTTP 200`
|
||||
- 2026-06-01 已把 `self_service` 的真实前置条件正式写死:
|
||||
- `self_service` 不会自动为目标用户充值
|
||||
- 如果目标用户 key 有效但余额不足,宿主会直接返回 `INSUFFICIENT_BALANCE`
|
||||
- 所以 `self_service_ready` 的真实验收前提仍然必须包含“用户余额已满足最小调用成本”
|
||||
- 2026-05-31 已继续把 `providers.html` 的页面内显式动作收口成独立 acceptance 入口:
|
||||
- 新增脚本:`scripts/acceptance/verify_provider_admin_actions.sh`
|
||||
- 覆盖范围:
|
||||
- `GET /api/packs`
|
||||
- `GET /api/hosts`
|
||||
- `GET /api/packs/{pack_id}/providers`
|
||||
- `POST /api/providers/{provider_id}/preview-import`
|
||||
- `POST /api/providers/{provider_id}/import`
|
||||
- `POST /api/provider-drafts`
|
||||
- `PUT /api/provider-drafts/{draft_id}`
|
||||
- `DELETE /api/provider-drafts/{draft_id}`
|
||||
- `POST /api/provider-drafts/{draft_id}/publish`
|
||||
- 同轮已补本地伪远端回归:
|
||||
- `scripts/test/test_real_host_scripts.sh`
|
||||
- 当前结论:
|
||||
- `providers.html` 不再只有动作级审计矩阵,页面内显式动作已经有可重复执行的 acceptance 入口
|
||||
- `rollback / reconcile / status / import-batches` 仍不属于这页当前显式 UI 能力
|
||||
- `subscription` 主链路已通过 latest fresh-host 复验:
|
||||
- MiniMax 53hk:`artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json`
|
||||
- DeepSeek 2166:`artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json`
|
||||
|
||||
@@ -57,8 +57,9 @@
|
||||
- 必须同时通过 `/v1/models` 和 `/v1/chat/completions`
|
||||
|
||||
4. `subscription` 和 `self_service` 的 probe key 语义不同
|
||||
- `subscription` 最终 probe key 是宿主 managed key
|
||||
- `self_service` 最终 probe key 是普通用户 gateway key
|
||||
- `subscription` 在显式提供 `access_api_key` 时,最终 probe key 也必须是**目标用户自己的 gateway key**
|
||||
- 只有在没有显式 `access_api_key` 的 fallback/运维场景,`subscription` 才允许走宿主 managed key
|
||||
|
||||
5. `self_service` 普通用户 key 的真实认证方式是 `Authorization: Bearer`
|
||||
- 不是 `x-api-key`
|
||||
@@ -170,6 +171,7 @@
|
||||
2. 普通用户 key 可用
|
||||
3. key 已绑定目标标准 group
|
||||
4. 用户有可用余额
|
||||
5. relay-manager 不会替用户自动充值;如果余额不足,宿主会直接返回 `INSUFFICIENT_BALANCE`
|
||||
|
||||
`subscription`:
|
||||
|
||||
@@ -178,6 +180,7 @@
|
||||
3. 目标 group 是 `subscription` 类型
|
||||
4. 用户有 active subscription
|
||||
5. key 已绑定该 subscription group
|
||||
6. 如果本轮导入显式传了 `access_api_key`,closure 与 completion smoke 必须用这把**真实用户 key** 验证,而不是 synthetic managed key
|
||||
|
||||
### 3.2 导入时最容易漏掉的三件事
|
||||
|
||||
|
||||
@@ -160,12 +160,18 @@
|
||||
- `billing_model_source=channel_mapped`
|
||||
|
||||
2. subscription 场景的 gateway probe 语义必须保持:
|
||||
- 最终 probe key 是宿主 managed key
|
||||
- 不是外部原始 `access_api_key`
|
||||
- closure artifact 必须把“请求传入的 key”和“实际探测使用的 key”分开表达:
|
||||
- `requested_probe_api_key` = 调用方传入原始 key
|
||||
- `effective_probe_key_source=managed_subscription` = 实际 gateway probe 走宿主 managed key
|
||||
- `probe_api_key` 仅继续保留给 `self_service` 向后兼容,不再用于 `subscription`
|
||||
- 当请求显式提供 `requested_probe_api_key` 时:
|
||||
- 最终 probe key 必须是**目标用户自己的 gateway key**
|
||||
- 不能再用宿主 synthetic managed key 把 closure 伪装成 `ready`
|
||||
- closure artifact 必须落 `requested_probe_api_key`
|
||||
- `effective_probe_key_source=requested_probe_api_key`
|
||||
- 仅当请求**没有**显式提供 `requested_probe_api_key` 时,才允许 fallback 到宿主 managed subscription key:
|
||||
- `effective_probe_key_source=managed_subscription`
|
||||
- `probe_api_key` 仅继续保留给 `self_service` 向后兼容,不再用于 `subscription`
|
||||
- 对于真实用户 subscription ready,最终必须同时满足:
|
||||
- `GET /api/v1/subscriptions/active` 非空
|
||||
- `GET /api/v1/groups/available` 可见目标 subscription group
|
||||
- 目标用户自己的 key 直探 `/v1/models` 与 `/v1/chat/completions` 为 `HTTP 200`
|
||||
|
||||
3. 任何 live 结论都必须先确认:
|
||||
- 在线 CRM 进程启动时间
|
||||
@@ -208,6 +214,6 @@
|
||||
- ❌ 把历史 review/task board 当当前 gate
|
||||
- ❌ 把历史 PASS artifact 当当前 latest-head 真相
|
||||
- ❌ 把 `/v1/models` 通过当成 completion 已通过
|
||||
- ❌ 把 subscription 场景原始 `access_api_key` 当成最终 probe key
|
||||
- ❌ 把 `subscription` closure 里的 `requested_probe_api_key` 误读成实际 gateway probe key
|
||||
- ❌ 在 `requested_probe_api_key` 已提供时,仍然用 managed synthetic key 把 `subscription_ready` 判真
|
||||
- ❌ 把旧 artifact 里的 `managed_subscription` 语义继续当成最新实现
|
||||
- ❌ 把 harness 参数错误(`PACK_PATH`、容器目标、probe auth)当成产品源码失败
|
||||
|
||||
@@ -78,12 +78,12 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) {
|
||||
func TestServiceCloseSubscriptionExplicitProbeAPIKeyUsesRequestedUserKey(t *testing.T) {
|
||||
host := &fakeClosureHost{
|
||||
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}},
|
||||
completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200},
|
||||
managedAccess: map[string]sub2api.SubscriptionAccessRef{
|
||||
"user-1": {UserID: "host-user-1", APIKey: "managed-user-key"},
|
||||
"user-1": {UserID: "user-1", APIKey: "caller-supplied-key"},
|
||||
},
|
||||
}
|
||||
service := NewService(host)
|
||||
@@ -97,14 +97,20 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if host.gatewayProbe.APIKey != "managed-user-key" {
|
||||
t.Fatalf("gateway probe api key = %q, want managed-user-key override", host.gatewayProbe.APIKey)
|
||||
if len(host.assigned) != 1 || host.assigned[0].UserID != "user-1" {
|
||||
t.Fatalf("assigned subscriptions = %+v, want requested user assignment", host.assigned)
|
||||
}
|
||||
if result.EffectiveProbeAPIKey != "managed-user-key" {
|
||||
t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey)
|
||||
if host.gatewayProbe.APIKey != "caller-supplied-key" {
|
||||
t.Fatalf("gateway probe api key = %q, want caller-supplied-key", host.gatewayProbe.APIKey)
|
||||
}
|
||||
if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription {
|
||||
t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription)
|
||||
if len(host.ensureRequests) != 1 || host.ensureRequests[0].ProbeAPIKey != "caller-supplied-key" {
|
||||
t.Fatalf("ensure requests = %+v, want probe api key forwarded", host.ensureRequests)
|
||||
}
|
||||
if result.EffectiveProbeAPIKey != "caller-supplied-key" {
|
||||
t.Fatalf("effective probe api key = %q, want caller-supplied-key", result.EffectiveProbeAPIKey)
|
||||
}
|
||||
if result.EffectiveProbeKeySource != ProbeKeySourceRequestedProbeAPIKey {
|
||||
t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceRequestedProbeAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +203,7 @@ func TestServiceCloseRepairsOpenAIResponsesCapabilityMismatch(t *testing.T) {
|
||||
|
||||
type fakeClosureHost struct {
|
||||
assigned []sub2api.AssignSubscriptionRequest
|
||||
ensureRequests []sub2api.EnsureSubscriptionAccessRequest
|
||||
managedAccess map[string]sub2api.SubscriptionAccessRef
|
||||
assignErr error
|
||||
gatewayProbe sub2api.GatewayAccessCheckRequest
|
||||
@@ -215,6 +222,7 @@ type fakeClosureHost struct {
|
||||
}
|
||||
|
||||
func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
|
||||
f.ensureRequests = append(f.ensureRequests, req)
|
||||
if ref, ok := f.managedAccess[req.UserSelector]; ok {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,31 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) prepareSubscriptionPlan(ctx context.Context, req ClosureRequest, plan closurePlan) (closurePlan, error) {
|
||||
requestedProbeAPIKey := strings.TrimSpace(req.ProbeAPIKey)
|
||||
for _, target := range req.Subscriptions {
|
||||
resolvedTarget := target.UserID
|
||||
if requestedProbeAPIKey != "" {
|
||||
if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
|
||||
UserID: resolvedTarget,
|
||||
GroupID: req.GroupID,
|
||||
DurationDays: target.DurationDays,
|
||||
}); err != nil {
|
||||
return closurePlan{}, fmt.Errorf("assign subscription for %s: %w", target.UserID, err)
|
||||
}
|
||||
accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
||||
UserSelector: target.UserID,
|
||||
GroupID: req.GroupID,
|
||||
ProbeAPIKey: requestedProbeAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return closurePlan{}, fmt.Errorf("ensure subscription access for %s: %w", target.UserID, err)
|
||||
}
|
||||
if strings.TrimSpace(accessRef.APIKey) != "" {
|
||||
plan.effectiveProbeAPIKey = strings.TrimSpace(accessRef.APIKey)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
accessRef, err := s.host.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
||||
UserSelector: target.UserID,
|
||||
GroupID: req.GroupID,
|
||||
|
||||
@@ -216,6 +216,29 @@ func (r batchImportRuntimeRunner) resolveValidationAPIKey(ctx context.Context, i
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
probeAPIKey := strings.TrimSpace(r.request.ProbeAPIKey)
|
||||
if probeAPIKey != "" {
|
||||
if _, err := r.hostClient.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
|
||||
UserID: r.request.SubscriptionUsers[0],
|
||||
GroupID: groupID,
|
||||
DurationDays: r.request.SubscriptionDays,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
accessRef, err := r.hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
||||
UserSelector: r.request.SubscriptionUsers[0],
|
||||
GroupID: groupID,
|
||||
ProbeAPIKey: probeAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(accessRef.APIKey) == "" {
|
||||
return probeAPIKey, nil
|
||||
}
|
||||
return strings.TrimSpace(accessRef.APIKey), nil
|
||||
}
|
||||
|
||||
accessRef, err := r.hostClient.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
||||
UserSelector: r.request.SubscriptionUsers[0],
|
||||
GroupID: groupID,
|
||||
|
||||
@@ -2239,6 +2239,9 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
|
||||
case r.URL.Path == "/v1/models" && strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer sk-relay-"):
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{{"id": "deepseek-v4-pro"}}})
|
||||
return
|
||||
case r.URL.Path == "/v1/models" && strings.TrimSpace(r.Header.Get("Authorization")) == "Bearer caller-probe-key":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{{"id": "deepseek-v4-pro"}}})
|
||||
return
|
||||
case r.URL.Path == "/v1/chat/completions" && strings.HasPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer sk-relay-"):
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": "chatcmpl_subscription",
|
||||
@@ -2251,6 +2254,32 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
|
||||
}},
|
||||
})
|
||||
return
|
||||
case r.URL.Path == "/v1/chat/completions" && strings.TrimSpace(r.Header.Get("Authorization")) == "Bearer caller-probe-key":
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": "chatcmpl_subscription",
|
||||
"choices": []map[string]any{{
|
||||
"index": 0,
|
||||
"message": map[string]any{
|
||||
"role": "assistant",
|
||||
"content": "pong",
|
||||
},
|
||||
}},
|
||||
})
|
||||
return
|
||||
case r.URL.Path == "/api/v1/admin/users/1/api-keys":
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"items": []map[string]any{{
|
||||
"id": 501,
|
||||
"key": "caller-probe-key",
|
||||
"name": "caller-key",
|
||||
}},
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.URL.Path == "/api/v1/admin/api-keys/501" && r.Method == http.MethodPut:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"api_key": map[string]any{"id": 501}}})
|
||||
return
|
||||
}
|
||||
baseHandler.ServeHTTP(w, r)
|
||||
}))
|
||||
@@ -2304,7 +2333,7 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
|
||||
PackPath: packPath,
|
||||
ProviderID: "deepseek",
|
||||
AccessAPIKey: "caller-probe-key",
|
||||
SubscriptionUsers: []string{"crm-user-1"},
|
||||
SubscriptionUsers: []string{"1"},
|
||||
SubscriptionDays: 30,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -2331,8 +2360,8 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
|
||||
if got, _ := payload["requested_probe_api_key"].(string); got != "caller-probe-key" {
|
||||
t.Fatalf("requested_probe_api_key = %q, want caller-probe-key", got)
|
||||
}
|
||||
if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" {
|
||||
t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got)
|
||||
if got, _ := payload["effective_probe_key_source"].(string); got != "requested_probe_api_key" {
|
||||
t.Fatalf("effective_probe_key_source = %q, want requested_probe_api_key", got)
|
||||
}
|
||||
batchRow, err := store.ImportBatches().GetByID(context.Background(), batchID)
|
||||
if err != nil {
|
||||
|
||||
@@ -167,6 +167,7 @@ func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite.
|
||||
return "", fmt.Errorf("subscription access closure missing subscription_users")
|
||||
}
|
||||
subscriptionDays := parseJSONInt(details["subscription_days"])
|
||||
requestedProbeAPIKey, _ := details["requested_probe_api_key"].(string)
|
||||
groupID, err := resolveManagedResourceHostIDByBatch(ctx, store, batch.ID, "group")
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -175,6 +176,31 @@ func reconcileProbeAPIKey(ctx context.Context, store *sqlite.DB, hostRow sqlite.
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
requestedProbeAPIKey = strings.TrimSpace(requestedProbeAPIKey)
|
||||
if requestedProbeAPIKey != "" {
|
||||
if subscriptionDays > 0 {
|
||||
if _, err := client.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
|
||||
UserID: subscriptionUsers[0],
|
||||
GroupID: groupID,
|
||||
DurationDays: subscriptionDays,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
||||
UserSelector: subscriptionUsers[0],
|
||||
GroupID: groupID,
|
||||
ProbeAPIKey: requestedProbeAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(accessRef.APIKey) == "" {
|
||||
return requestedProbeAPIKey, nil
|
||||
}
|
||||
return strings.TrimSpace(accessRef.APIKey), nil
|
||||
}
|
||||
|
||||
accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{
|
||||
UserSelector: subscriptionUsers[0],
|
||||
GroupID: groupID,
|
||||
|
||||
@@ -151,6 +151,7 @@ type AssignSubscriptionRequest struct {
|
||||
type EnsureSubscriptionAccessRequest struct {
|
||||
UserSelector string
|
||||
GroupID string
|
||||
ProbeAPIKey string
|
||||
}
|
||||
|
||||
type SubscriptionAccessRef struct {
|
||||
|
||||
@@ -851,7 +851,7 @@ func TestAssignSubscriptionWithMock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSubscriptionAccessWithMock(t *testing.T) {
|
||||
func TestEnsureSubscriptionAccessManagedProbeWithMock(t *testing.T) {
|
||||
var calls []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
@@ -911,6 +911,45 @@ func TestEnsureSubscriptionAccessWithMock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSubscriptionAccessRealUserProbeWithMock(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/admin/users/1/api-keys":
|
||||
w.Write([]byte(`{"data":{"items":[{"id":501,"key":"caller-probe-key","name":"user-key"}]}}`))
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501":
|
||||
var req struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode bind api key request: %v", err)
|
||||
}
|
||||
if req.GroupID != 101 {
|
||||
t.Fatalf("group id = %d, want 101", req.GroupID)
|
||||
}
|
||||
w.Write([]byte(`{"data":{"api_key":{"id":501}}}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, _ := NewClient(srv.URL, WithBearerToken("admin-token"))
|
||||
ref, err := client.EnsureSubscriptionAccess(context.Background(), EnsureSubscriptionAccessRequest{
|
||||
UserSelector: "1",
|
||||
GroupID: "101",
|
||||
ProbeAPIKey: "caller-probe-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ref.UserID != "1" {
|
||||
t.Fatalf("user id = %q, want 1", ref.UserID)
|
||||
}
|
||||
if ref.APIKey != "caller-probe-key" {
|
||||
t.Fatalf("api key = %q, want caller-probe-key", ref.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGatewayAccessWithMock(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer gk" {
|
||||
|
||||
@@ -52,6 +52,9 @@ func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscri
|
||||
if err != nil {
|
||||
return SubscriptionAccessRef{}, fmt.Errorf("parse group id %q: %w", groupID, err)
|
||||
}
|
||||
if probeAPIKey := strings.TrimSpace(req.ProbeAPIKey); probeAPIKey != "" {
|
||||
return c.ensureRealSubscriptionAccess(ctx, selector, groupInt, probeAPIKey)
|
||||
}
|
||||
|
||||
identity := buildManagedSubscriptionIdentity(selector, groupID)
|
||||
user, err := c.findManagedSubscriptionUser(ctx, identity.Email)
|
||||
@@ -82,7 +85,7 @@ func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscri
|
||||
if err != nil {
|
||||
return SubscriptionAccessRef{}, err
|
||||
}
|
||||
if err := c.bindManagedSubscriptionAPIKey(ctx, keyRecord.ID, groupInt); err != nil {
|
||||
if err := c.bindAPIKeyGroup(ctx, keyRecord.ID, groupInt); err != nil {
|
||||
return SubscriptionAccessRef{}, err
|
||||
}
|
||||
return SubscriptionAccessRef{UserID: strconv.FormatInt(user.ID, 10), APIKey: identity.CustomKey}, nil
|
||||
@@ -297,11 +300,55 @@ func (c *Client) findManagedSubscriptionAPIKey(ctx context.Context, userID int64
|
||||
return nil, fmt.Errorf("managed api key %q not found for user %d", identity.KeyName, userID)
|
||||
}
|
||||
|
||||
func (c *Client) bindManagedSubscriptionAPIKey(ctx context.Context, keyID, groupID int64) error {
|
||||
func (c *Client) ensureRealSubscriptionAccess(ctx context.Context, selector string, groupID int64, probeAPIKey string) (SubscriptionAccessRef, error) {
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(selector), 10, 64)
|
||||
if err != nil {
|
||||
return SubscriptionAccessRef{}, fmt.Errorf("parse real subscription user id %q: %w", selector, err)
|
||||
}
|
||||
keyRecord, err := c.findUserAPIKeyByRawKey(ctx, userID, probeAPIKey)
|
||||
if err != nil {
|
||||
return SubscriptionAccessRef{}, err
|
||||
}
|
||||
if err := c.bindAPIKeyGroup(ctx, keyRecord.ID, groupID); err != nil {
|
||||
return SubscriptionAccessRef{}, err
|
||||
}
|
||||
return SubscriptionAccessRef{
|
||||
UserID: strconv.FormatInt(userID, 10),
|
||||
APIKey: probeAPIKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) findUserAPIKeyByRawKey(ctx context.Context, userID int64, rawKey string) (*adminAPIKeyRecord, error) {
|
||||
statusCode, _, body, err := c.perform(ctx, http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys?page=1&page_size=1000&sort_by=created_at&sort_order=desc", userID), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user api keys: %w", err)
|
||||
}
|
||||
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
||||
return nil, newHTTPError(http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys", userID), statusCode, body)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Items []adminAPIKeyRecord `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode user api keys response: %w", err)
|
||||
}
|
||||
trimmedRawKey := strings.TrimSpace(rawKey)
|
||||
for _, item := range envelope.Data.Items {
|
||||
if strings.TrimSpace(item.Key) == trimmedRawKey {
|
||||
key := item
|
||||
return &key, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("probe api key not found for user %d", userID)
|
||||
}
|
||||
|
||||
func (c *Client) bindAPIKeyGroup(ctx context.Context, keyID, groupID int64) error {
|
||||
payload := map[string]any{"group_id": groupID}
|
||||
statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind managed api key group: %w", err)
|
||||
return fmt.Errorf("bind api key group: %w", err)
|
||||
}
|
||||
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
||||
return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), statusCode, body)
|
||||
|
||||
Reference in New Issue
Block a user