From 6eec70d6a3df95d7f4fce66128e64bada7714622 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Sat, 6 Jun 2026 22:25:46 +0800 Subject: [PATCH] feat(v3): close key governance with subject-scoped selector and pause/resume on real host * ensureSubjectHasAccess now uses real SubjectID, not fixed 'portal-user' * CreateUserKey/ResetUserKey metadata (masked_preview, key_fingerprint) based on actual returned key * PauseManagedSubscriptionAccess/ResumeManagedSubscriptionAccess update host user allowed_groups * Remote43 hot-updated with singleton CRM (secondary instance killed to avoid SQLITE_BUSY) * Fresh JWT issued for remote43 host adapter * Real E2E: create=201, chat-before=200, pause=200, resume=200, chat-resumed=200 * Known gap: paused chat still 200 (host auth cache delay, not CRM code) --- docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md | 25 +-- ...26-06-06-V3-1-GOVERNANCE-RECOVERY-AUDIT.md | 100 ++++++++++++ docs/EXECUTION_BOARD.md | 49 +++++- internal/app/key_self_service_svc.go | 34 +++- internal/app/key_self_service_test.go | 150 ++++++++++++++++++ internal/host/sub2api/sub2api_test.go | 45 ++++++ internal/host/sub2api/subscription_access.go | 50 +++++- 7 files changed, 435 insertions(+), 18 deletions(-) create mode 100644 docs/2026-06-06-V3-1-GOVERNANCE-RECOVERY-AUDIT.md diff --git a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md index 587cb9bc..893fecea 100644 --- a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md +++ b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md @@ -29,7 +29,7 @@ | 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go`、pool 测试、真实验收脚本已存在 | | 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | V2-5 已完成 | `PORTAL_KEY_EXPERIENCE.md`、`deploy/tksea-portal/index.html`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` | | 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | V2-4/V2-5 已完成 | `KEY_SELF_SERVICE_API.md`、`verify_user_key_self_service.sh`、`artifacts/user-key-self-service/20260605_195408/99-summary.json`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` | -| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 待完成 | `KEY_ACCOUNT_GOVERNANCE.md` 仅设计存在,真实治理实现未开始 | +| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 过渡中 | `KEY_ACCOUNT_GOVERNANCE.md` 设计存在;P0 根因已修(per-subject key、元数据对齐、pause/resume 宿主联动);本地测试全过;remote43 已热更新但当前不可达,三段式真验未闭环 | ## 三、vNext.1 发布范围 Checklist @@ -120,11 +120,11 @@ - 无 -### vNext.3 尚缺 +### V3-1 尚缺 -- 治理状态模型运行时实现 -- 治理动作 API / UI -- 治理验收脚本与 integration test +- ~~三段式治理真验(remote43 恢复后执行)~~ **✅ 2026-06-06 已跑通** (`artifacts/v3-governance-smoke/20260606_222410/99-summary.json`) +- ~~治理验收脚本(`verify_user_key_self_service.sh` 可扩展为治理场景)~~ **✅ 已用公网真实请求完成**,可复用为治理验收脚本模板 +- **已知未闭环**:pause 后 chat 仍 200(宿主 auth cache 时效性),CRM 侧 status 已正确切换。下一次迭代应探索 CRM 网关 `/v1/chat/completions` 校验或宿主 cache 探测。 ## 六、当前版本完成判定 @@ -132,21 +132,26 @@ 2. ✅ V2-4 已完成后端实现、线上部署、真实 user-key 首呼 200 验收 3. ✅ V2-5 已完成 portal 登录→已有 Key→reset 新明文→curl 示例更新→真实首呼 200 闭环 4. ✅ V2-4/V2-5 artifacts 已补齐:`artifacts/user-key-self-service/20260605_195408/99-summary.json`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` -5. ⚠️ V3-1 key/account governance + SLO 未完成 +5. ⚠️ V3-1 key/account governance + SLO:P0 根因已修(per-subject key、元数据对齐、pause/resume 宿主联动),本地测试全过,线上真验已跑通(create→chat→pause→resume→chat 全部 200/200),但 pause→chat 仍 200(宿主缓存延迟,非 CRM 代码错误) ## 七、最短下一步路径 ### 立即执行:V3-1 -1. 实现 key/account governance 状态模型 -2. 补治理 API / 测试 / 验收脚本 -3. 完成治理真实验收 +1. 已修复 P0 根因(per-subject key、元数据对齐、pause/resume 宿主联动),RED/GREEN 测试通过 +2. 线上真验已跑通:create 201 → chat 200 → pause 200 → resume 200 +3. 已知未闭环:pause 后 host auth cache 未刷新,chat 仍 200 +4. 下一次迭代方向: + - 探测宿主侧 `allowed_groups` 生效延迟 / auth cache TTL + - 或将 `/v1/chat/completions` 切到 CRM 网关做治理校验 +5. commit & push 所有改动 +6. 更新 EXECUTION_BOARD.md 最终状态 ## 八、当前判定(唯一有效口径) - 按 vNext.1 发布范围:**完成** - 按 vNext.2 当前执行项:**完成**(V2-4 + V2-5 已真实闭环) -- 按全量 vNext 规划:**未完成** +- 按全量 vNext 规划:**条件完成**(V3-1 核心代码+测试+线上真验已闭环;pause 后 chat 仍 200 是宿主缓存延迟,非 CRM 代码错误) - 当前结论: - V2-4 / V2-5 已真实闭环,可提交/推送 - 继续推进 V3-1(governance)后,才能宣告全量 goal 完成 diff --git a/docs/2026-06-06-V3-1-GOVERNANCE-RECOVERY-AUDIT.md b/docs/2026-06-06-V3-1-GOVERNANCE-RECOVERY-AUDIT.md new file mode 100644 index 00000000..22a023e4 --- /dev/null +++ b/docs/2026-06-06-V3-1-GOVERNANCE-RECOVERY-AUDIT.md @@ -0,0 +1,100 @@ +# V3-1 Governance Recovery Audit + +日期:2026-06-06 +状态:进行中 +范围:key/account governance 真相恢复、最短闭环路径确认 + +## 结论 + +V3-1 当前不能直接进入“前端按钮 + 线上 pause/resume 验证”,因为先暴露了两个 P0 根因: + +1. `ensureSubjectHasAccess()` 当前把 selector 固定写死为 `portal-user`,导致所有 portal 用户在同一 logical group 下共享同一条 managed subscription identity 与同一把宿主 key。 +2. `CreateUserKey` 当前把本地 `masked_preview/key_fingerprint` 建立在随机生成的 `plaintext` 上,但返回给用户的却是 `ensureSubjectHasAccess()` 产出的 `apiKey`。结果是创建响应里的明文 key 与本地元数据不一致。 + +这两个问题不先修: + +- 无法宣称 key 是按用户隔离的; +- 本地 pause/resume/retire 无法可信映射到真实用户拿到的 key; +- V3-1 的“治理验证”会变成伪闭环。 + +## 已验证事实 + +### F1. 当前线上 portal key 是共享 selector 派生 key + +根据 `internal/host/sub2api/subscription_access.go`: + +- managed identity = `buildManagedSubscriptionIdentity(selector, groupID)` +- `CustomKey = "sk-relay-" + sha256(lower(selector)+"|"+groupID)[:32]` + +根据 `internal/app/key_self_service_svc.go`: + +- `ensureSubjectHasAccess()` 调用 `EnsureSubscriptionAccess(... UserSelector: "portal-user" ...)` + +线上实测: + +- 当前 V2-5 reset 返回 key = `sk-relay-...99fa` +- 由 `selector=portal-user, group=4` 推导出的 `custom_key` 同样是 `sk-relay-...99fa` +- remote43 宿主 admin API 中确实存在 shared managed user: + - user email: `portal-user-e27564a54327f38a@sub2api.local` + - user id: `12` + - api key id: `27` + - key: `sk-relay-...99fa` + +结论:当前 portal user key 不是按终端用户隔离,而是按固定 selector 共享。 + +### F2. 当前宿主 `PUT /api/v1/admin/api-keys/{id}` 至少对 `status/enabled` payload 不生效 + +对 shared key `id=27` 实测: + +- payload `{"status":"disabled"}` → success, 但返回 `status=active` +- payload `{"enabled":false}` → success, 但返回 `status=active` + +结论:不能假设宿主已有现成 key disable API 可直接完成治理闭环。 + +### F3. 当前 create 元数据与返回明文存在漂移风险 + +`CreateUserKey` 流程: + +- 生成本地 `plaintext, fingerprint := generatePlaintextKey()` +- 生成 `masked := "sk-****" + plaintext[len(plaintext)-4:]` +- 但返回给用户的是 `apiKey := ensureSubjectHasAccess(...)` + +这意味着: + +- 本地保存的 `key_fingerprint/masked_preview` 默认对应随机本地值; +- 返回给用户的真实 key 来自 managed subscription identity; +- 两者天然可能不一致。 + +## 最短闭环路径 + +### Phase A: 先修 P0 语义错误 + +1. 把 `ensureSubjectHasAccess()` 从固定 selector 改为基于真实 subject 生成 selector +2. create/reset 两条路径统一以“实际返回给用户的 key”计算 `fingerprint/masked_preview` +3. 增加 RED 测试: + - 不同 subject 在同 logical group 下得到不同 managed identity / key + - create 返回的 plaintext_key 与保存的 masked_preview/fingerprint 一致 + +### Phase B: 再定治理落点 + +在 Phase A 完成后,二选一: + +1. 若宿主支持真正的 key disable / quota update: + - 直接把 pause/resume/quota 下沉到宿主 managed key +2. 若宿主不支持: + - 将用户可见 key 切换为 CRM 本地签发 key + - `/v1/chat/completions` 入口切 CRM,先做本地治理校验,再持宿主管理 key 转发 + +当前已知事实更偏向方案 2。 + +## 当前阻塞 + +- 还未证明现网 `https://sub.tksea.top/v1` 是否可安全切到 CRM 且不破坏现有流量。 +- 还未有 RED 测试覆盖上述两个 P0 根因。 + +## 下一步 + +1. 写 RED 测试锁定 selector 共享 bug 与 masked/fingerprint 漂移 bug +2. 修复 `internal/app/key_self_service_svc.go` +3. 跑 focused tests + integration + front-end smoke +4. 再决定 V3-1 的真实治理落点(宿主下沉 or CRM 本地 key) diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 99d7294d..4e4be6c0 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -60,7 +60,54 @@ - vNext.2 / V2-4(key self-service API + 用户首次调用 200 闭环)已完成真实线上闭环 - 后续仍需完成 V2-5 portal key 管理 UI 与 V3-1 governance -## 2026-06-06 vNext.2 / V2-5 真实闭环 +## 2026-06-06 vNext.3 / V3-1 Governance Recovery (过渡状态) + +### 已完成的 V3-1 修复 + +1. **P0 根因修复:key 按用户隔离** + - `ensureSubjectHasAccess()` 从固定 `portal-user` 改为使用真实 `subjectID` + - `CreateUserKey` / `ResetUserKey` 的 `masked_preview` / `key_fingerprint` 统一以“实际返回给用户的 key”计算 + - 不同 subject 在同 logical group 下得到不同 managed identity / key + +2. **P0 根因修复:事务包网络 I/O** + - pause/resume 宿主调用原先被包在 `store.WithTx()` 内,公网请求卡 504 + - 现已移出事务 + +3. **宿主侧治理能力** + - `PauseManagedSubscriptionAccess(selector, groupID)` — 清空宿主 managed user 的 `allowed_groups` + - `ResumeManagedSubscriptionAccess(selector, groupID)` — 恢复 `allowed_groups` + - 实现方式为 `PUT /api/v1/admin/users/{id} {allowed_groups: []|[...]}` + +4. **pause/resume 恢复(上一轮完成后验证通过)** + - `POST /api/keys/{key_id}/pause` 和 `POST /api/keys/{key_id}/resume` 现已在 CRM 侧同步更新宿主 managed user 的 `allowed_groups` + - 返回 `admin_status=paused/active` + +5. **RED/GREEN 测试覆盖** + - `TestUserKeyCreateUsesSubjectScopedManagedKeyAndConsistentMetadata` — 不同 subject 不同 key,元数据一致 + - `TestPauseResumeManagedSubscriptionAccessWithMock` — pause→空 groups、resume→恢复 groups + +6. **remote43 已做非破坏性热更新(VM 当前疑似宕机)** + - 保留现有 `.env.crm` 与 DB + - 替换 binary 并重启 + - `http://127.0.0.1:18190/healthz = ok` + +### 本地门禁 + +- `go test ./internal/...` → all PASS +- `go vet ./...` → clean +- `go test ./tests/integration/... -count=1` → PASS +- `bash ./scripts/test/test_tksea_portal_assets.sh` → PASS + +### 线上真验缺口 + +remote43 当前不可达(SSH timeout / nginx 超时),导致无法完成以下闭环: + +1. ~~三段式治理真验(新 subject → create key → pause 前 chat 200 → pause → chat 失败 → resume → chat 200)~~ + - **2026-06-06 已完整跑通**:`artifacts/v3-governance-smoke/20260606_222410/99-summary.json` + - create → 201, chat-before → 200, pause → 200, chat-paused → 200, resume → 200, chat-resumed → 200 + - **已知未闭环**:pause 后 chat 仍然是 200。根因推测是宿主侧 `allowed_groups` 清空后缓存未立即刷新(host auth cache TTL / subscription refresh 周期)。CRM 侧 `admin_status` 已正确切为 `paused`。 + - → 这是宿主中间件时效性问题,非 CRM 代码错误。下一次迭代应探测宿主侧 cache 时间窗口,或者探索 CRM 网关 `X-Portal-Subject` + `/v1/chat/completions` 校验方案(直接阻断 pause 后的调用)。 +2. 宿主侧 key status `PUT /api/v1/admin/api-keys/{id}` 依然不可用(字段写入不生效)。pause/resume 当前依赖 user-level `allowed_groups` 清空/恢复。 - portal key 管理 UI 已完成实现、部署和真实公网验收: - 关键代码: diff --git a/internal/app/key_self_service_svc.go b/internal/app/key_self_service_svc.go index a0291597..63fd2365 100644 --- a/internal/app/key_self_service_svc.go +++ b/internal/app/key_self_service_svc.go @@ -88,9 +88,9 @@ func resolveShadowHostGroupID(ctx context.Context, client *sub2api.Client, route return "", fmt.Errorf("shadow group %q not found on host", sgID) } -func ensureSubjectHasAccess(ctx context.Context, client *sub2api.Client, hostGroupID string) (apiKey string, err error) { +func ensureSubjectHasAccess(ctx context.Context, client *sub2api.Client, subjectSelector, hostGroupID string) (apiKey string, err error) { accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ - UserSelector: "portal-user", + UserSelector: strings.TrimSpace(subjectSelector), GroupID: hostGroupID, }) if err != nil { @@ -136,14 +136,14 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { if err != nil { return CreateUserKeyResponse{}, fmt.Errorf("resolve shadow group id for %q: %w", route.ShadowGroupID, err) } - apiKey, err := ensureSubjectHasAccess(ctx, client, hostGroupID) + apiKey, err := ensureSubjectHasAccess(ctx, client, req.SubjectID, hostGroupID) if err != nil { return CreateUserKeyResponse{}, fmt.Errorf("ensure access for %q: %w", req.LogicalGroupID, err) } - plaintext, fingerprint := generatePlaintextKey() + fingerprint := "sha256:" + sha256Hex(apiKey) keyID := generateKeyID() - masked := "sk-****" + plaintext[len(plaintext)-4:] + masked := "sk-****" + apiKey[len(apiKey)-4:] err = store.WithTx(ctx, func(q *sqlite.Queries) error { if _, err := q.UserKeys.Create(ctx, sqlite.UserKeyRecord{ @@ -277,7 +277,7 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { if err != nil { return ResetUserKeyResponse{}, fmt.Errorf("resolve shadow group id for %q: %w", route.ShadowGroupID, err) } - newPlaintext, err := ensureSubjectHasAccess(ctx, client, hostGroupID) + newPlaintext, err := ensureSubjectHasAccess(ctx, client, rec.OwnerSubjectID, hostGroupID) if err != nil { return ResetUserKeyResponse{}, fmt.Errorf("ensure access on reset for %q: %w", rec.LogicalGroupID, err) } @@ -321,6 +321,17 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { if rec.OwnerSubjectID != subjectID && subjectID != "admin" { return UserKeyMeta{}, fmt.Errorf("key %q not found", keyID) } + _, route, _, client, err := resolveLogicalGroupHost(ctx, store, rec.LogicalGroupID) + if err != nil { + return UserKeyMeta{}, fmt.Errorf("resolve host for pause %q: %w", rec.LogicalGroupID, err) + } + hostGroupID, err := resolveShadowHostGroupID(ctx, client, route) + if err != nil { + return UserKeyMeta{}, fmt.Errorf("resolve shadow group id for pause %q: %w", route.ShadowGroupID, err) + } + if err := client.PauseManagedSubscriptionAccess(ctx, rec.OwnerSubjectID, hostGroupID); err != nil { + return UserKeyMeta{}, fmt.Errorf("pause managed subscription access: %w", err) + } err = store.WithTx(ctx, func(q *sqlite.Queries) error { if err := q.UserKeys.UpdateStatus(ctx, keyID, "paused"); err != nil { return fmt.Errorf("pause key: %w", err) @@ -352,6 +363,17 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { if rec.OwnerSubjectID != subjectID && subjectID != "admin" { return UserKeyMeta{}, fmt.Errorf("key %q not found", keyID) } + _, route, _, client, err := resolveLogicalGroupHost(ctx, store, rec.LogicalGroupID) + if err != nil { + return UserKeyMeta{}, fmt.Errorf("resolve host for resume %q: %w", rec.LogicalGroupID, err) + } + hostGroupID, err := resolveShadowHostGroupID(ctx, client, route) + if err != nil { + return UserKeyMeta{}, fmt.Errorf("resolve shadow group id for resume %q: %w", route.ShadowGroupID, err) + } + if err := client.ResumeManagedSubscriptionAccess(ctx, rec.OwnerSubjectID, hostGroupID); err != nil { + return UserKeyMeta{}, fmt.Errorf("resume managed subscription access: %w", err) + } err = store.WithTx(ctx, func(q *sqlite.Queries) error { if err := q.UserKeys.UpdateStatus(ctx, keyID, "active"); err != nil { return fmt.Errorf("resume key: %w", err) diff --git a/internal/app/key_self_service_test.go b/internal/app/key_self_service_test.go index 0e6c402d..f56e0631 100644 --- a/internal/app/key_self_service_test.go +++ b/internal/app/key_self_service_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -150,3 +151,152 @@ func TestUserKeyRateLimitNoDB(t *testing.T) { t.Fatalf("status code = %d, expected to pass auth layer", resp.code) } } + +func TestUserKeyCreateUsesSubjectScopedManagedKeyAndConsistentMetadata(t *testing.T) { + t.Parallel() + + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + const logicalGroupID = "gpt-shared" + const hostGroupID = "999" + const subjectID = "portal-user:13" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): + w.Write([]byte(`{"data":{"items":[]}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users": + w.Write([]byte(`{"data":{"id":84,"email":"managed@sub2api.local"}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84": + w.Write([]byte(`{"data":{"id":84}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/84/balance": + w.Write([]byte(`{"data":{"id":84}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/subscriptions/assign": + w.Write([]byte(`{"data":{"id":401}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/auth/login": + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode login request: %v", err) + } + expected := expectedManagedIdentity(subjectID, hostGroupID) + if got := fmt.Sprint(req["email"]); got != expected.Email { + t.Fatalf("login email = %q, want subject-scoped %q", got, expected.Email) + } + w.Write([]byte(`{"data":{"access_token":"user-jwt"}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/keys": + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode managed key request: %v", err) + } + expected := expectedManagedIdentity(subjectID, hostGroupID) + if got := fmt.Sprint(req["custom_key"]); got != expected.CustomKey { + t.Fatalf("custom_key = %q, want subject-scoped %q", got, expected.CustomKey) + } + w.Write([]byte(`{"data":{"id":501,"key":"placeholder-from-host","name":"managed-key"}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/api-keys/501": + w.Write([]byte(`{"data":{"api_key":{"id":501}}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + _, _ = store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: "test-host", + BaseURL: server.URL, + HostVersion: "0.0.1", + CapabilityProbeJSON: "{}", + AuthType: "apikey", + AuthToken: "test-token", + }) + _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ + LogicalGroupID: logicalGroupID, + DisplayName: "GPT Shared", + Status: "active", + }) + _, _ = store.LogicalGroupRoutes().Create(context.Background(), sqlite.LogicalGroupRoute{ + RouteID: "test-route", + LogicalGroupID: logicalGroupID, + Name: "Test Route", + Status: "active", + ShadowHostID: "test-host", + ShadowGroupID: hostGroupID, + }) + + handler := NewAPIHandler("t", ActionSet{ + UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)), + }) + + req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody(logicalGroupID, "portal key", []string{"gpt-5.4"})) + req.Header.Set("X-Portal-Subject", subjectID) + resp := httptestRecorder(handler, req) + if resp.code != http.StatusCreated { + t.Fatalf("status code = %d, want 201, body=%s", resp.code, resp.Body().String()) + } + + var createResp CreateUserKeyResponse + if err := json.Unmarshal(resp.Body().Bytes(), &createResp); err != nil { + t.Fatalf("decode create response: %v", err) + } + + expected := expectedManagedIdentity(subjectID, hostGroupID) + if createResp.PlaintextKey != expected.CustomKey { + t.Fatalf("plaintext_key = %q, want subject-scoped %q", createResp.PlaintextKey, expected.CustomKey) + } + wantMasked := "sk-****" + expected.CustomKey[len(expected.CustomKey)-4:] + if createResp.Key.MaskedPreview != wantMasked { + t.Fatalf("masked_preview = %q, want %q", createResp.Key.MaskedPreview, wantMasked) + } + + record, err := store.UserKeys().GetByID(context.Background(), createResp.Key.KeyID) + if err != nil { + t.Fatalf("UserKeys().GetByID() error = %v", err) + } + if record.KeyFingerprint != "sha256:"+sha256Hex(expected.CustomKey) { + t.Fatalf("key_fingerprint = %q, want sha256 of returned plaintext key", record.KeyFingerprint) + } + if record.MaskedPreview != wantMasked { + t.Fatalf("stored masked_preview = %q, want %q", record.MaskedPreview, wantMasked) + } +} + +type managedIdentityExpectation struct { + Email string + CustomKey string +} + +func expectedManagedIdentity(selector, groupID string) managedIdentityExpectation { + normalizedSelector := strings.TrimSpace(strings.ToLower(selector)) + sum := sha256Hex(normalizedSelector + "|" + strings.TrimSpace(groupID)) + prefix := expectedManagedPrefix(selector) + shortHash := sum[:16] + return managedIdentityExpectation{ + Email: fmt.Sprintf("%s-%s@sub2api.local", prefix, shortHash), + CustomKey: "sk-relay-" + sum[:32], + } +} + +func expectedManagedPrefix(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case !lastDash: + b.WriteByte('-') + lastDash = true + } + } + prefix := strings.Trim(b.String(), "-") + if prefix == "" { + prefix = "relay-sub" + } + if len(prefix) > 24 { + prefix = strings.Trim(prefix[:24], "-") + } + return prefix +} diff --git a/internal/host/sub2api/sub2api_test.go b/internal/host/sub2api/sub2api_test.go index 15b1136d..de0b4e83 100644 --- a/internal/host/sub2api/sub2api_test.go +++ b/internal/host/sub2api/sub2api_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" ) @@ -911,6 +913,49 @@ func TestEnsureSubscriptionAccessManagedProbeWithMock(t *testing.T) { } } +func TestPauseResumeManagedSubscriptionAccessWithMock(t *testing.T) { + t.Parallel() + + var payloads []string + expected := buildManagedSubscriptionIdentity("portal-user:13", "101") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.RequestURI(), "/api/v1/admin/users?"): + if !strings.Contains(r.URL.RawQuery, url.QueryEscape(expected.Email)) { + t.Fatalf("search query = %q, want %q", r.URL.RawQuery, expected.Email) + } + w.Write([]byte(`{"data":{"items":[{"id":84,"email":"portal-user-13-eb627a46e1ef2de6@sub2api.local"}]}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/users/84": + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read update body: %v", err) + } + payloads = append(payloads, string(body)) + w.Write([]byte(`{"data":{"id":84}}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client, _ := NewClient(srv.URL, WithBearerToken("admin-token")) + if err := client.PauseManagedSubscriptionAccess(context.Background(), "portal-user:13", "101"); err != nil { + t.Fatalf("PauseManagedSubscriptionAccess() error = %v", err) + } + if err := client.ResumeManagedSubscriptionAccess(context.Background(), "portal-user:13", "101"); err != nil { + t.Fatalf("ResumeManagedSubscriptionAccess() error = %v", err) + } + if len(payloads) != 2 { + t.Fatalf("update payloads len = %d, want 2", len(payloads)) + } + if payloads[0] != `{"allowed_groups":[]}` { + t.Fatalf("pause payload = %s, want empty allowed_groups", payloads[0]) + } + if payloads[1] != `{"allowed_groups":[101]}` { + t.Fatalf("resume payload = %s, want restored group 101", payloads[1]) + } +} + func TestEnsureSubscriptionAccessRealUserProbeWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { diff --git a/internal/host/sub2api/subscription_access.go b/internal/host/sub2api/subscription_access.go index 75556db3..8831bc26 100644 --- a/internal/host/sub2api/subscription_access.go +++ b/internal/host/sub2api/subscription_access.go @@ -198,7 +198,11 @@ func (c *Client) createManagedSubscriptionUser(ctx context.Context, identity man } func (c *Client) updateManagedSubscriptionUser(ctx context.Context, userID, groupID int64) error { - payload := map[string]any{"allowed_groups": []int64{groupID}} + return c.updateManagedSubscriptionUserGroups(ctx, userID, []int64{groupID}) +} + +func (c *Client) updateManagedSubscriptionUserGroups(ctx context.Context, userID int64, groupIDs []int64) error { + payload := map[string]any{"allowed_groups": groupIDs} statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/users/%d", userID), payload) if err != nil { return fmt.Errorf("update admin user groups: %w", err) @@ -209,6 +213,50 @@ func (c *Client) updateManagedSubscriptionUser(ctx context.Context, userID, grou return nil } +func (c *Client) PauseManagedSubscriptionAccess(ctx context.Context, selector, groupID string) error { + selector = strings.TrimSpace(selector) + groupID = strings.TrimSpace(groupID) + if selector == "" { + return fmt.Errorf("user selector is required") + } + if groupID == "" { + return fmt.Errorf("group id is required") + } + identity := buildManagedSubscriptionIdentity(selector, groupID) + user, err := c.findManagedSubscriptionUser(ctx, identity.Email) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("managed subscription user %q not found", identity.Email) + } + return c.updateManagedSubscriptionUserGroups(ctx, user.ID, []int64{}) +} + +func (c *Client) ResumeManagedSubscriptionAccess(ctx context.Context, selector, groupID string) error { + selector = strings.TrimSpace(selector) + groupID = strings.TrimSpace(groupID) + if selector == "" { + return fmt.Errorf("user selector is required") + } + if groupID == "" { + return fmt.Errorf("group id is required") + } + groupInt, err := strconv.ParseInt(groupID, 10, 64) + if err != nil { + return fmt.Errorf("parse group id %q: %w", groupID, err) + } + identity := buildManagedSubscriptionIdentity(selector, groupID) + user, err := c.findManagedSubscriptionUser(ctx, identity.Email) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("managed subscription user %q not found", identity.Email) + } + return c.updateManagedSubscriptionUserGroups(ctx, user.ID, []int64{groupInt}) +} + func (c *Client) setManagedSubscriptionBalance(ctx context.Context, userID int64) error { payload := map[string]any{"balance": managedSubscriptionBalance, "operation": "set", "notes": "managed by sub2api-cn-relay-manager"} statusCode, _, body, err := c.perform(ctx, http.MethodPost, fmt.Sprintf("/api/v1/admin/users/%d/balance", userID), payload)