From 5b59ad749047ed58cc3436465f0c484818826c3c Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 5 Jun 2026 19:58:02 +0800 Subject: [PATCH] feat(vnext2): close user key self-service on real host --- .gitignore | 2 + docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md | 158 ++++----- docs/EXECUTION_BOARD.md | 42 +++ internal/app/http_api.go | 3 + internal/app/key_self_service.go | 39 ++- internal/app/key_self_service_svc.go | 307 ++++++++++++++++-- internal/app/key_self_service_test.go | 152 +++++++++ .../0016_user_key_control_plane.sql | 26 ++ internal/store/sqlite/db.go | 12 + .../store/sqlite/subject_rate_limits_repo.go | 59 ++++ .../sqlite/user_key_audit_events_repo.go | 101 ++++++ .../user_key_control_plane_repo_test.go | 101 ++++++ internal/store/sqlite/user_keys_repo.go | 51 ++- .../verify_user_key_self_service.sh | 292 ++++++++++++----- .../test/test_user_key_self_service_script.sh | 67 ++++ 15 files changed, 1217 insertions(+), 195 deletions(-) create mode 100644 internal/app/key_self_service_test.go create mode 100644 internal/store/migrations/0016_user_key_control_plane.sql create mode 100644 internal/store/sqlite/subject_rate_limits_repo.go create mode 100644 internal/store/sqlite/user_key_audit_events_repo.go create mode 100644 internal/store/sqlite/user_key_control_plane_repo_test.go create mode 100755 scripts/test/test_user_key_self_service_script.sh diff --git a/.gitignore b/.gitignore index 458ea180..5a4ea898 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ artifacts/real-host-acceptance/ artifacts/host-capability/ artifacts/default-data/ artifacts/phase2-routing-matrix/ +artifacts/fresh-vnext1-acceptance/ +artifacts/user-key-self-service/ internal/store/sqlite/?_pragma=foreign_keys(1) # Local build outputs diff --git a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md index 2bc782ac..07feb1c4 100644 --- a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md +++ b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md @@ -10,27 +10,26 @@ ## 一、先说结论 -当前状态:条件完成(vNext.1) +当前状态:未完成(全量 vNext) 说明: -- vNext.1 全部 5 项发布项(宿主协议能力矩阵、模型池抽象、pool映射、默认链路准入、幂等初始化)已完成代码/文档/发布闭环 -- DEFAULT_CHAIN_ADMISSION.md 与 DEFAULT_DATA_IDEMPOTENT_RELEASE_GATE.md 均已审核通过 -- 三远端推送成功,新二进制已在 remote43 真实运行(PID 156445, healthz=ok) -- fresh 三层验收 artifact 已生成(`artifacts/fresh-vnext1-acceptance/20260605_114200-final/`),L1 upstream 已验证,L2 host 部分验证,L3 user-key 因 CRM-only 部署模式无宿主进程在当前架构下不可验证 -- vNext.2 与 vNext.3 保留为设计占位,未进入实现 +- vNext.1 已完成代码/文档/发布闭环。 +- vNext.2 当前只完成了 V2-4:key self-service API + 用户首次调用 200 真实线上闭环。 +- vNext.2 的 V2-5(portal key 管理 UI)尚未开始验收,vNext.3(治理/SLO)尚未开始实现。 +- 因此按“全量 vNext goal”口径仍然是未完成;按阶段口径可判定:vNext.1 完成、vNext.2 部分完成(V2-4 已完成)。 ## 二、5 个核心问题 Checklist(全量 vNext 目标) 真相源:`docs/EXECUTION_BOARD.md` -| 问题 | 规划要求 | 当前状态 | 证据 | -| ---------------------------------------------------- | -------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------- | -| 1. 宿主协议稳定支持哪些主流大模型 | 必须有真实协议矩阵 + 真实验收脚本 + 当前输出 | vNext.1 已闭环 | `verify_host_protocol_matrix.sh` 已存在,首轮 4 个 upstream live probe 已产出 artifact | -| 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go` + `pool_routing_test.go` + `verify_host_pool_routing.sh` 已存在 | -| 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | vNext.2 设计已存在,实现未开始 | `PORTAL_KEY_EXPERIENCE.md` 设计已写 | -| 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | vNext.2 设计已存在,实现未开始 | `KEY_SELF_SERVICE_API.md` 设计已写,`verify_user_key_self_service.sh` skeleton 已就绪 | -| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | vNext.3 设计已存在,实现未开始 | `KEY_ACCOUNT_GOVERNANCE.md` 设计已写,`key_policy.go` 等代码 vNext.3 启动后补充 | +| 问题 | 规划要求 | 当前状态 | 证据 | +| ---------------------------------------------------- | -------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1. 宿主协议稳定支持哪些主流大模型 | 必须有真实协议矩阵 + 真实验收脚本 + 当前输出 | vNext.1 已闭环 | `verify_host_protocol_matrix.sh` 与相关 artifact 已存在 | +| 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go`、pool 测试、真实验收脚本已存在 | +| 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | V2-5 待完成 | `PORTAL_KEY_EXPERIENCE.md` 已审核通过,但 UI 闭环尚未完成 | +| 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | V2-4 已完成 | `KEY_SELF_SERVICE_API.md`、`verify_user_key_self_service.sh`、`artifacts/user-key-self-service/20260605_195408/99-summary.json` | +| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 待完成 | `KEY_ACCOUNT_GOVERNANCE.md` 仅设计存在,真实治理实现未开始 | ## 三、vNext.1 发布范围 Checklist @@ -38,120 +37,125 @@ ### 3.1 发布项 -| vNext.1 发布项 | 要求 | 当前状态 | 说明 | -| ------------------------------------ | -------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | -| 宿主协议能力矩阵 | 真实探测 + 文档结论 | 已完成 | `docs/2026-06-04-HOST_PROTOCOL_MATRIX.md` 已存在,`verify_host_protocol_matrix.sh` 可执行,首轮 live probe 已产出 | -| 模型池抽象 | ModelPool 抽象 | 已完成 | 已有实现 + 测试 | -| pool 到 priority failover 运行面映射 | runtime import / logical*group*\* 映射 | 已完成 | 已接线并通过 provision 测试 | -| 默认链路准入规则 | 文档化硬规则 | 已审核通过 | `docs/2026-06-04-DEFAULT_CHAIN_ADMISSION.md` 已审核通过 | -| 幂等默认数据/初始化脚本进入发布前置 | runbook 或脚本说明 | 已审核通过 | `docs/2026-06-04-DEFAULT_DATA_IDEMPOTENT_RELEASE_GATE.md` 已审核通过,配套 `scripts/setup_default_data.sh` 已实现 | +| vNext.1 发布项 | 要求 | 当前状态 | 说明 | +| ------------------------------------ | ----------------------------------- | ---------- | ------------------------------------------------------------------------------------------- | +| 宿主协议能力矩阵 | 真实探测 + 文档结论 | 已完成 | 已有脚本 + live artifact | +| 模型池抽象 | ModelPool 抽象 | 已完成 | 已有实现 + 测试 | +| pool 到 priority failover 运行面映射 | runtime import / logical_group 映射 | 已完成 | 已接线并通过 provision 测试 | +| 默认链路准入规则 | 文档化硬规则 | 已审核通过 | `docs/2026-06-04-DEFAULT_CHAIN_ADMISSION.md` | +| 幂等默认数据/初始化脚本进入发布前置 | runbook 或脚本说明 | 已审核通过 | `docs/2026-06-04-DEFAULT_DATA_IDEMPOTENT_RELEASE_GATE.md` + `scripts/setup_default_data.sh` | ### 3.2 本版本验收命令 -| 验收项 | 规划要求 | 当前状态 | 证据 | -| ------------------------------------------------------------------- | -------- | -------- | ----------------------------------------------------------- | -| `go test ./internal/host/sub2api -run Capability -count=1` | 必跑 | 已完成 | `TestBuildCapabilityInventory/TestProbeCapabilities` 均通过 | -| `go test ./internal/provision -run ModelPool -count=1` | 必跑 | 已完成 | `TestNewModelPool` 等通过 | -| `bash ./scripts/test/test_host_protocol_matrix_script.sh` | 必跑 | 已完成 | PASS | -| 至少一组真实 artifact:upstream probe + host probe + user-key probe | 必须具备 | 条件完成 | L1/L2 已产出,L3 因 CRM-only 部署模式无宿主进程不可验证 | +| 验收项 | 规划要求 | 当前状态 | 证据 | +| ------------------------------------------------------------------- | -------- | -------- | ----------------------------------------------- | +| `go test ./internal/host/sub2api -run Capability -count=1` | 必跑 | 已完成 | 已通过 | +| `go test ./internal/provision -run ModelPool -count=1` | 必跑 | 已完成 | 已通过 | +| `bash ./scripts/test/test_host_protocol_matrix_script.sh` | 必跑 | 已完成 | PASS | +| 至少一组真实 artifact:upstream probe + host probe + user-key probe | 必须具备 | 已完成 | fresh 验收 + 后续 V2-4 user-key artifact 已补齐 | ### 3.3 本版本必须产出 -| 产物 | 规划要求 | 当前状态 | -| -------------------------------------------- | -------- | --------------------------------------------------- | -| `docs/2026-06-04-vnext-release-scope.md` | 必须存在 | 已完成 | -| `docs/2026-06-xx-HOST_PROTOCOL_MATRIX.md` | 必须存在 | 已完成(`2026-06-04-HOST_PROTOCOL_MATRIX.md`) | -| `docs/2026-06-04-MODEL_POOL_DESIGN.md` | 必须存在 | 已完成 | -| `docs/2026-06-04-DEFAULT_CHAIN_ADMISSION.md` | 必须存在 | 已审核通过 | -| 幂等初始化/默认数据 runbook 或脚本说明 | 必须存在 | 已审核通过 + `scripts/setup_default_data.sh` 已实现 | +| 产物 | 规划要求 | 当前状态 | +| -------------------------------------------- | -------- | --------------------------- | +| `docs/2026-06-04-vnext-release-scope.md` | 必须存在 | 已完成 | +| `docs/2026-06-04-HOST_PROTOCOL_MATRIX.md` | 必须存在 | 已完成 | +| `docs/2026-06-04-MODEL_POOL_DESIGN.md` | 必须存在 | 已完成 | +| `docs/2026-06-04-DEFAULT_CHAIN_ADMISSION.md` | 必须存在 | 已审核通过 | +| 幂等初始化/默认数据 runbook 或脚本说明 | 必须存在 | 已审核通过 + 配套脚本已实现 | ## 四、按 TDD Plan 分阶段状态 ### Phase 0 / 1 / 1.5 - 规格文档、capability inventory、host protocol matrix 基础骨架:已闭环 -- release scope 已审核通过 +- release scope 已落地为实际执行边界 -状态:vNext.1 已闭环(条件验收) +状态:vNext.1 已闭环 ### Phase 2 - Task 2.1 模型池抽象:完成 - Task 2.2 宿主池化映射编排:完成 -- Task 2.3 真实池化路由验收:完成(脚本+集成测试) +- Task 2.3 真实池化路由验收:完成(脚本 + 集成测试) 状态:vNext.1 已闭环 -### Phase 3 +### Phase 3(vNext.2) -- Task 3.1 用户信息架构设计:设计已存在,实现推迟到 vNext.2 -- Task 3.2 key 发放 API:设计已存在,实现推迟到 vNext.2 -- Task 3.3 用户首次调用闭环:设计已存在,实现推迟到 vNext.2 +- Task 3.1 用户信息架构设计:设计已存在 +- Task 3.2 key 发放 API:已实现并上线验证 +- Task 3.3 用户首次调用闭环:已完成真实 `chat/completions=200` +- 尚缺:portal key 管理 UI(V2-5) -状态:未开始(vNext.2 设计占位) +状态:部分完成(V2-4 已闭环,V2-5 未完成) -### Phase 4 +### Phase 4(vNext.3) -- Task 4.1 状态模型与治理语义:设计已存在,实现推迟到 vNext.3 -- Task 4.2 管理页治理动作:设计已存在,实现推迟到 vNext.3 -- Task 4.3 真实治理验收:设计已存在,实现推迟到 vNext.3 +- Task 4.1 状态模型与治理语义:仅设计存在 +- Task 4.2 管理页治理动作:未实现 +- Task 4.3 真实治理验收:未开始 -状态:未开始(vNext.3 设计占位) +状态:未开始 ### Phase 5 - Task 5.1 默认链路准入规则:vNext.1 已闭环 -- Task 5.2 最终多层验证:vNext.1 已通过质量门禁 +- Task 5.2 多层验证:vNext.1 + V2-4 当前均已有真实 artifact -状态:vNext.1 已闭环 +状态:部分完成(整体 vNext 仍未完成) -## 五、当前缺失文件 / 脚本 / 测试(已核对真实存在性) +## 五、当前缺失文件 / 脚本 / 测试(按真实存在性校对) -### vNext.1 已全部闭环 +### 已完成 - `docs/2026-06-04-DEFAULT_CHAIN_ADMISSION.md` — 已审核通过 -- `docs/2026-06-04-DEFAULT_DATA_IDEMPOTENT_RELEASE_GATE.md` — 已审核通过,配套脚本完备 +- `docs/2026-06-04-DEFAULT_DATA_IDEMPOTENT_RELEASE_GATE.md` — 已审核通过 - `scripts/acceptance/verify_host_pool_routing.sh` — 已存在 - `scripts/acceptance/verify_host_protocol_matrix.sh` — 已存在 -- `scripts/acceptance/verify_user_key_self_service.sh` — 已存在(Phase 0 skeleton) +- `scripts/acceptance/verify_user_key_self_service.sh` — 已从 skeleton 升级为真实验收脚本 +- `internal/app/key_self_service_test.go` — 已存在 -### vNext.2 设计已存在,实现未开始 +### vNext.2 尚缺 -- `docs/2026-06-04-PORTAL_KEY_EXPERIENCE.md` -- `docs/2026-06-04-KEY_SELF_SERVICE_API.md` -- `docs/2026-06-04-KEY_SECURITY_MODEL.md` +- `deploy/tksea-portal/` 中 portal key 管理 UI 的实现与真实前端验收(V2-5) -### vNext.3 设计已存在,实现未开始 +### vNext.3 尚缺 -- `docs/2026-06-04-KEY_ACCOUNT_GOVERNANCE.md` -- `docs/2026-06-04-SLO_AND_OBSERVABILITY.md` - -### 真缺失代码 / 测试(vNext.2/vNext.3 启动后补充) - -- `internal/app/key_self_service_test.go`(vNext.2) -- `internal/access/key_policy.go`(vNext.3) -- `tests/integration/key_governance_test.go`(vNext.3) +- 治理状态模型运行时实现 +- 治理动作 API / UI +- 治理验收脚本与 integration test ## 六、当前版本完成判定 1. ✅ vNext.1 全部 5 项发布项已完成代码/文档/发布闭环 -2. ✅ 两份 release gate 文档已审核通过 -3. ✅ 三远端已推送新二进制已在 remote43 运行 -4. ⚠️ fresh 三层验收 L1/L2 已闭环,L3 user-key 因 CRM-only 部署模式在当前架构下不适用 -5. ✅ vNext.2/vNext.3 设计已存在但实现明确推迟 +2. ✅ V2-4 已完成后端实现、线上部署、真实 user-key 首呼 200 验收 +3. ✅ user-key artifact 已补齐:`artifacts/user-key-self-service/20260605_195408/99-summary.json` +4. ⚠️ V2-5 portal key 管理 UI 未完成 +5. ⚠️ V3-1 key/account governance + SLO 未完成 ## 七、最短下一步路径 -### 立即执行:vNext.2 Phase 3 +### 立即执行:V2-5 -1. 写 `PORTAL_KEY_EXPERIENCE.md` — 用户 portal key 信息架构设计(已有设计文档,但需进入实现规划) -2. 实现 key self-service API + 前端承接 + 用户首次 200 闭环 -3. 完成验收脚本 `verify_user_key_self_service.sh` 从 skeleton 升级为真实验收 +1. 实现 `deploy/tksea-portal/` 的 key 管理 UI +2. 执行前端门禁: + - `bash ./scripts/test/test_tksea_portal_assets.sh` + - `bash ./scripts/test/verify_frontend_smoke.sh` + - 若涉及显式动作,执行 `bash ./scripts/acceptance/verify_provider_admin_actions.sh` 或对应 portal 验收 +3. 部署 remote43 并做公网页面验收 + +### 然后执行:V3-1 + +1. 实现 key/account governance 状态模型 +2. 补治理 API / 测试 / 验收脚本 +3. 完成治理真实验收 ## 八、当前判定(唯一有效口径) -- 按 vNext.1 发布范围:**条件完成**(三项发布项全部完成,L3 缺口为架构限制非代码功能缺失) -- 按全量 vNext 规划:**未完成**(Phase 3/4/5 已按 release scope 推迟) +- 按 vNext.1 发布范围:**完成** +- 按 vNext.2 当前执行项:**部分完成**(V2-4 完成,V2-5 未完成) +- 按全量 vNext 规划:**未完成** - 当前结论: - - vNext.1 可视为可发布状态 - - 进入 vNext.2(Phase 3:portal key 自助 + 用户首次 200 闭环) + - V2-4 已真实闭环,可进入提交/推送 + - 继续推进 V2-5(portal key UI)与 V3-1(governance)后,才能宣告全量 goal 完成 diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 9bea13ad..b6b2162f 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -20,6 +20,48 @@ 2. `portal-admin-api` nginx 反代自动指向 18190(新 CRM) 3. `/metrics` Prometheus 端点已在公网通过 portal-admin-api 反代可访问 +## 2026-06-05 vNext.2 / V2-4 真实闭环 + +- 已完成 user-key self-service 第二轮实现并部署到 remote43 生产 CRM: + - 关键代码: + - `internal/app/key_self_service_svc.go` + - `internal/app/key_self_service.go` + - `internal/app/http_api.go` + - `internal/store/migrations/0016_user_key_control_plane.sql` + - `internal/store/sqlite/user_key_audit_events_repo.go` + - `internal/store/sqlite/subject_rate_limits_repo.go` + - `scripts/acceptance/verify_user_key_self_service.sh` +- root cause 收敛链路: + - 第一阶段失败不是代码未接线,而是 remote43 `hosts.auth_token` 中保存的宿主 bearer JWT 已过期 + - 刷新宿主 token 后,第二阶段失败点收敛为线上 bootstrap 路由数据错误: + - `logical_group_routes(route_id=asxs, logical_group_id=gpt-shared)` 最初错误指向 `shadow_group_id=3` + - 宿主 `/api/v1/admin/groups` 实测证明: + - `group_id=3` -> `subscription_type=standard` + - `group_id=4` -> `subscription_type=subscription` + - 修正后真实路由应指向 `shadow_group_id=4` +- remote43 当前生产数据修正(经用户明确授权后执行): + - `UPDATE logical_group_routes SET shadow_group_id=4 WHERE route_id='asxs' AND logical_group_id='gpt-shared';` +- 真实线上验收已通过: + - 命令: + - `USER_CHAT_BASE='https://sub.tksea.top' USER_SUBJECT_ID='acceptance-user-20260605' bash scripts/acceptance/verify_user_key_self_service.sh --run` + - artifact: + - `artifacts/user-key-self-service/20260605_195408/00-env.json` + - `artifacts/user-key-self-service/20260605_195408/10-create.body.json` + - `artifacts/user-key-self-service/20260605_195408/13-reset.body.json` + - `artifacts/user-key-self-service/20260605_195408/20-chat.body.json` + - `artifacts/user-key-self-service/20260605_195408/99-summary.json` + - 结果: + - `POST /api/keys` -> `201` + - `GET /api/keys` -> `200` + - `GET /api/keys/{key_id}` -> `200` + - `POST /api/keys/{key_id}/reset` -> `200` + - `POST https://sub.tksea.top/v1/chat/completions` with user key -> `200` +- 当前结论: + - vNext.2 / V2-4(key self-service API + 用户首次调用 200 闭环)已完成真实线上闭环 + - 仍未完成的 vNext 范围为: + - V2-5 portal key 管理 UI + - V3-1 key/account governance + SLO/治理闭环 + ## 2026-05-22 当前真相 - 当前主目录 `artifacts/real-host-acceptance/` 已只保留最终证据;历史调试样本已迁到 `artifacts/real-host-acceptance-archive/` diff --git a/internal/app/http_api.go b/internal/app/http_api.go index 1072fe35..3b782b45 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -441,6 +441,7 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha mux.HandleFunc("POST /api/keys", func(w http.ResponseWriter, r *http.Request) { handleCreateUserKey(w, r, ukh) }) mux.HandleFunc("GET /api/keys", func(w http.ResponseWriter, r *http.Request) { handleListUserKeys(w, r, ukh) }) mux.HandleFunc("GET /api/keys/{key_id}", func(w http.ResponseWriter, r *http.Request) { handleGetUserKey(w, r, ukh) }) + mux.HandleFunc("POST /api/keys/{key_id}/reset", func(w http.ResponseWriter, r *http.Request) { handleResetUserKey(w, r, ukh) }) mux.HandleFunc("POST /api/keys/{key_id}/pause", func(w http.ResponseWriter, r *http.Request) { handlePauseUserKey(w, r, ukh) }) mux.HandleFunc("POST /api/keys/{key_id}/resume", func(w http.ResponseWriter, r *http.Request) { handleResumeUserKey(w, r, ukh) }) mux.HandleFunc("DELETE /api/keys/{key_id}", func(w http.ResponseWriter, r *http.Request) { handleDeleteUserKey(w, r, ukh) }) @@ -1305,6 +1306,8 @@ func classifyError(err error) *httpError { return &httpError{StatusCode: http.StatusBadRequest, Code: "provider_not_found", Message: message} case strings.Contains(message, "not found"): return &httpError{StatusCode: http.StatusNotFound, Code: "not_found", Message: message} + case strings.Contains(message, "rate limit") || strings.Contains(message, "rate_limited"): + return &httpError{StatusCode: http.StatusTooManyRequests, Code: "rate_limited", Message: message} case strings.Contains(message, "pack path") || strings.Contains(message, "pack dir") || strings.Contains(message, "required") || strings.Contains(message, "decode"): return &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: message} default: diff --git a/internal/app/key_self_service.go b/internal/app/key_self_service.go index 3cba817f..54ab09e0 100644 --- a/internal/app/key_self_service.go +++ b/internal/app/key_self_service.go @@ -12,7 +12,7 @@ import ( func generatePlaintextKey() (string, string) { buf := make([]byte, 32) - rand.Read(buf) + _, _ = rand.Read(buf) plaintext := "sk-" + hex.EncodeToString(buf) hash := sha256.Sum256([]byte(plaintext)) return plaintext, "sha256:" + hex.EncodeToString(hash[:]) @@ -22,6 +22,7 @@ type UserKeyHandler struct { createFn func(ctx context.Context, req CreateUserKeyRequest) (CreateUserKeyResponse, error) listFn func(ctx context.Context, subjectID string) ([]UserKeyMeta, error) getFn func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) + resetFn func(ctx context.Context, keyID, subjectID string) (ResetUserKeyResponse, error) pauseFn func(ctx context.Context, keyID, subjectID, reason string) (UserKeyMeta, error) resumeFn func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) deleteFn func(ctx context.Context, keyID, subjectID string) error @@ -39,6 +40,12 @@ type CreateUserKeyResponse struct { PlaintextKey string `json:"plaintext_key,omitempty"` } +type ResetUserKeyResponse struct { + PlaintextKey string `json:"plaintext_key,omitempty"` + MaskedPreview string `json:"masked_preview"` + AdminStatus string `json:"admin_status"` +} + type UserKeyMeta struct { KeyID string `json:"key_id"` MaskedPreview string `json:"masked_preview"` @@ -53,8 +60,13 @@ type UserKeyMeta struct { } func (h *UserKeyHandler) extractSubjectID(r *http.Request) (string, *httpError) { + for _, header := range []string{"X-Portal-Subject", "X-User-Subject", "X-Forwarded-User"} { + if subjectID := strings.TrimSpace(r.Header.Get(header)); subjectID != "" { + return subjectID, nil + } + } if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") { - token := strings.TrimPrefix(hdr, "Bearer ") + token := strings.TrimSpace(strings.TrimPrefix(hdr, "Bearer ")) if token != "" { n := 8 if len(token) < n { @@ -135,6 +147,29 @@ func handleGetUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) writeJSON(w, http.StatusOK, key) } +func handleResetUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) { + if h == nil || h.resetFn == nil { + writeSvcNotImplError(w) + return + } + subjectID, httpErr := h.extractSubjectID(r) + if httpErr != nil { + writeHTTPError(w, httpErr) + return + } + keyID := r.PathValue("key_id") + if keyID == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "missing_key_id", Message: "key_id required"}) + return + } + resp, svcErr := h.resetFn(r.Context(), keyID, subjectID) + if svcErr != nil { + writeHTTPError(w, classifyError(svcErr)) + return + } + writeJSON(w, http.StatusOK, resp) +} + func handlePauseUserKey(w http.ResponseWriter, r *http.Request, h *UserKeyHandler) { if h == nil || h.pauseFn == nil { writeSvcNotImplError(w) diff --git a/internal/app/key_self_service_svc.go b/internal/app/key_self_service_svc.go index 38010afa..a0291597 100644 --- a/internal/app/key_self_service_svc.go +++ b/internal/app/key_self_service_svc.go @@ -3,14 +3,23 @@ package app import ( "context" "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" "fmt" "math/big" + "strconv" + "strings" + "time" + "sub2api-cn-relay-manager/internal/host/sub2api" "sub2api-cn-relay-manager/internal/store/sqlite" ) const ( - keyIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + keyIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + defaultKeyRateLimitPerHour = 5 + defaultKeyResetPerDay = 2 ) func generateKeyID() string { @@ -23,45 +32,161 @@ func generateKeyID() string { return "key_" + string(b) } +// resolveLogicalGroupHost resolves a logical_group_id to host + shadow group host resource ID. +func resolveLogicalGroupHost(ctx context.Context, store *sqlite.DB, logicalGroupID string) (sqlite.LogicalGroup, sqlite.LogicalGroupRoute, sqlite.Host, *sub2api.Client, error) { + group, err := store.LogicalGroups().GetByLogicalGroupID(ctx, logicalGroupID) + if err != nil { + return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("logical group %q: %w", logicalGroupID, err) + } + routes, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, logicalGroupID) + if err != nil { + return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("list routes for %q: %w", logicalGroupID, err) + } + if len(routes) == 0 { + return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("no active route for logical group %q", logicalGroupID) + } + // pick first active route by priority + var firstRoute *sqlite.LogicalGroupRoute + for i, r := range routes { + if isActiveStatus(r.Status) { + firstRoute = &routes[i] + break + } + } + if firstRoute == nil { + return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("no active route for logical group %q", logicalGroupID) + } + hostRow, err := store.Hosts().GetByHostID(ctx, firstRoute.ShadowHostID) + if err != nil { + return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("host %q: %w", firstRoute.ShadowHostID, err) + } + client, err := newSub2APIClient(hostRow.BaseURL, authFromStoredHost(hostRow)) + if err != nil { + return sqlite.LogicalGroup{}, sqlite.LogicalGroupRoute{}, sqlite.Host{}, nil, fmt.Errorf("host client %q: %w", hostRow.HostID, err) + } + return group, *firstRoute, hostRow, client, nil +} + +// resolveShadowHostGroupID resolves a shadow_group_id from a route to a host-resolved group ID. +func resolveShadowHostGroupID(ctx context.Context, client *sub2api.Client, route sqlite.LogicalGroupRoute) (string, error) { + sgID := strings.TrimSpace(route.ShadowGroupID) + // If already a numeric ID, use as-is + if _, err := strconv.ParseInt(sgID, 10, 64); err == nil { + return sgID, nil + } + // Otherwise look up via managed resources + result, err := client.ListManagedResources(ctx, sub2api.ListManagedResourcesRequest{GroupName: sgID}) + if err != nil { + return "", fmt.Errorf("list managed groups for %q: %w", sgID, err) + } + if len(result.Groups) == 1 { + return result.Groups[0].ID, nil + } + if len(result.Groups) > 1 { + return "", fmt.Errorf("multiple host groups matched shadow_group_id %q", sgID) + } + 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) { + accessRef, err := client.EnsureSubscriptionAccess(ctx, sub2api.EnsureSubscriptionAccessRequest{ + UserSelector: "portal-user", + GroupID: hostGroupID, + }) + if err != nil { + return "", fmt.Errorf("ensure subscription access: %w", err) + } + apiKey = strings.TrimSpace(accessRef.APIKey) + if apiKey == "" { + return "", fmt.Errorf("managed subscription access returned empty api key") + } + return apiKey, nil +} + func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { return &UserKeyHandler{ createFn: func(ctx context.Context, req CreateUserKeyRequest) (CreateUserKeyResponse, error) { + if strings.TrimSpace(req.SubjectID) == "" { + return CreateUserKeyResponse{}, &httpError{StatusCode: 401, Code: "unauthorized", Message: "user credentials required"} + } + if strings.TrimSpace(req.LogicalGroupID) == "" { + return CreateUserKeyResponse{}, &httpError{StatusCode: 400, Code: "bad_request", Message: "logical_group_id is required"} + } store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return CreateUserKeyResponse{}, fmt.Errorf("open store: %w", err) } defer store.Close() + windowStart := time.Now().UTC().Format("2006-01-02T15:00:00Z") + count, err := store.SubjectRateLimits().IncrementWindow(ctx, req.SubjectID, "create", windowStart) + if err != nil { + return CreateUserKeyResponse{}, fmt.Errorf("increment create rate limit: %w", err) + } + if count > defaultKeyRateLimitPerHour { + return CreateUserKeyResponse{}, &httpError{StatusCode: 429, Code: "rate_limited", Message: "create key rate limit exceeded"} + } + + // Resolve logical group → host → group ID → ensure subscription access + _, route, hostRow, client, err := resolveLogicalGroupHost(ctx, store, req.LogicalGroupID) + if err != nil { + return CreateUserKeyResponse{}, fmt.Errorf("resolve host for %q: %w", req.LogicalGroupID, err) + } + hostGroupID, err := resolveShadowHostGroupID(ctx, client, route) + if err != nil { + return CreateUserKeyResponse{}, fmt.Errorf("resolve shadow group id for %q: %w", route.ShadowGroupID, err) + } + apiKey, err := ensureSubjectHasAccess(ctx, client, hostGroupID) + if err != nil { + return CreateUserKeyResponse{}, fmt.Errorf("ensure access for %q: %w", req.LogicalGroupID, err) + } + plaintext, fingerprint := generatePlaintextKey() keyID := generateKeyID() masked := "sk-****" + plaintext[len(plaintext)-4:] - _, err = store.UserKeys().Create(ctx, sqlite.UserKeyRecord{ - KeyID: keyID, - OwnerSubjectID: req.SubjectID, - KeyFingerprint: fingerprint, - MaskedPreview: masked, - DisplayName: req.DisplayName, - LogicalGroupID: req.LogicalGroupID, - AllowedModels: req.AllowedModels, - AdminStatus: "active", - QuotaStatus: "ok", + err = store.WithTx(ctx, func(q *sqlite.Queries) error { + if _, err := q.UserKeys.Create(ctx, sqlite.UserKeyRecord{ + KeyID: keyID, + OwnerSubjectID: req.SubjectID, + KeyFingerprint: fingerprint, + MaskedPreview: masked, + DisplayName: strings.TrimSpace(req.DisplayName), + LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), + AllowedModels: req.AllowedModels, + AdminStatus: "active", + QuotaStatus: "ok", + }); err != nil { + return fmt.Errorf("create key: %w", err) + } + if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{ + EventID: generateKeyID(), + ActorSubjectID: req.SubjectID, + ActorRole: "user", + TargetKeyID: keyID, + Action: "create", + Result: "success", + Reason: "self service create via host " + hostRow.HostID, + }); err != nil { + return fmt.Errorf("audit create key: %w", err) + } + return nil }) if err != nil { - return CreateUserKeyResponse{}, fmt.Errorf("create key: %w", err) + return CreateUserKeyResponse{}, err } return CreateUserKeyResponse{ Key: UserKeyMeta{ KeyID: keyID, MaskedPreview: masked, - DisplayName: req.DisplayName, - LogicalGroupID: req.LogicalGroupID, + DisplayName: strings.TrimSpace(req.DisplayName), + LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), AllowedModels: req.AllowedModels, AdminStatus: "active", QuotaStatus: "ok", }, - PlaintextKey: plaintext, + PlaintextKey: apiKey, }, nil }, listFn: func(ctx context.Context, subjectID string) ([]UserKeyMeta, error) { @@ -105,7 +230,7 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { return UserKeyMeta{}, fmt.Errorf("get key: %w", err) } if rec.OwnerSubjectID != subjectID && subjectID != "admin" { - return UserKeyMeta{}, fmt.Errorf("not found") + return UserKeyMeta{}, fmt.Errorf("key %q not found", keyID) } return UserKeyMeta{ KeyID: rec.KeyID, @@ -120,24 +245,98 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { ExpiresAt: rec.ExpiresAt, }, nil }, + resetFn: func(ctx context.Context, keyID, subjectID string) (ResetUserKeyResponse, error) { + store, err := sqlite.Open(ctx, sqliteDSN) + if err != nil { + return ResetUserKeyResponse{}, fmt.Errorf("open store: %w", err) + } + defer store.Close() + + rec, err := store.UserKeys().GetByID(ctx, keyID) + if err != nil { + return ResetUserKeyResponse{}, fmt.Errorf("get key: %w", err) + } + if rec.OwnerSubjectID != subjectID && subjectID != "admin" { + return ResetUserKeyResponse{}, fmt.Errorf("key %q not found", keyID) + } + windowStart := time.Now().UTC().Format("2006-01-02T00:00:00Z") + count, err := store.SubjectRateLimits().IncrementWindow(ctx, subjectID, "reset", windowStart) + if err != nil { + return ResetUserKeyResponse{}, fmt.Errorf("increment reset rate limit: %w", err) + } + if count > defaultKeyResetPerDay { + return ResetUserKeyResponse{}, &httpError{StatusCode: 429, Code: "rate_limited", Message: "reset key rate limit exceeded"} + } + + // Re-resolve host access to get a fresh key + _, route, _, client, err := resolveLogicalGroupHost(ctx, store, rec.LogicalGroupID) + if err != nil { + return ResetUserKeyResponse{}, fmt.Errorf("resolve host for %q: %w", rec.LogicalGroupID, err) + } + hostGroupID, err := resolveShadowHostGroupID(ctx, client, route) + if err != nil { + return ResetUserKeyResponse{}, fmt.Errorf("resolve shadow group id for %q: %w", route.ShadowGroupID, err) + } + newPlaintext, err := ensureSubjectHasAccess(ctx, client, hostGroupID) + if err != nil { + return ResetUserKeyResponse{}, fmt.Errorf("ensure access on reset for %q: %w", rec.LogicalGroupID, err) + } + + hostFingerprint := "sha256:" + sha256Hex(newPlaintext) + masked := "sk-****" + newPlaintext[len(newPlaintext)-4:] + + err = store.WithTx(ctx, func(q *sqlite.Queries) error { + if err := q.UserKeys.UpdateSecret(ctx, keyID, hostFingerprint, masked, "active"); err != nil { + return fmt.Errorf("reset key: %w", err) + } + if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{ + EventID: generateKeyID(), + ActorSubjectID: subjectID, + ActorRole: "user", + TargetKeyID: keyID, + Action: "reset", + Result: "success", + Reason: "self service reset", + }); err != nil { + return fmt.Errorf("audit reset key: %w", err) + } + return nil + }) + if err != nil { + return ResetUserKeyResponse{}, err + } + return ResetUserKeyResponse{PlaintextKey: newPlaintext, MaskedPreview: masked, AdminStatus: "active"}, nil + }, pauseFn: func(ctx context.Context, keyID, subjectID, reason string) (UserKeyMeta, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return UserKeyMeta{}, fmt.Errorf("open store: %w", err) } defer store.Close() - if err := store.UserKeys().UpdateStatus(ctx, keyID, "paused"); err != nil { - return UserKeyMeta{}, fmt.Errorf("pause key: %w", err) + + rec, err := store.UserKeys().GetByID(ctx, keyID) + if err != nil { + return UserKeyMeta{}, fmt.Errorf("get key: %w", err) } - rec, _ := store.UserKeys().GetByID(ctx, keyID) - if rec != nil { - return UserKeyMeta{ - KeyID: rec.KeyID, - MaskedPreview: rec.MaskedPreview, - AdminStatus: rec.AdminStatus, - }, nil + if rec.OwnerSubjectID != subjectID && subjectID != "admin" { + return UserKeyMeta{}, fmt.Errorf("key %q not found", keyID) } - return UserKeyMeta{KeyID: keyID, AdminStatus: "paused"}, nil + 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) + } + if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{ + EventID: generateKeyID(), ActorSubjectID: subjectID, ActorRole: "user", + TargetKeyID: keyID, Action: "pause", Result: "success", Reason: strings.TrimSpace(reason), + }); err != nil { + return fmt.Errorf("audit pause key: %w", err) + } + return nil + }) + if err != nil { + return UserKeyMeta{}, err + } + return UserKeyMeta{KeyID: keyID, MaskedPreview: rec.MaskedPreview, AdminStatus: "paused"}, nil }, resumeFn: func(ctx context.Context, keyID, subjectID string) (UserKeyMeta, error) { store, err := sqlite.Open(ctx, sqliteDSN) @@ -145,10 +344,30 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { return UserKeyMeta{}, fmt.Errorf("open store: %w", err) } defer store.Close() - if err := store.UserKeys().UpdateStatus(ctx, keyID, "active"); err != nil { - return UserKeyMeta{}, fmt.Errorf("resume key: %w", err) + + rec, err := store.UserKeys().GetByID(ctx, keyID) + if err != nil { + return UserKeyMeta{}, fmt.Errorf("get key: %w", err) } - return UserKeyMeta{KeyID: keyID, AdminStatus: "active"}, nil + if rec.OwnerSubjectID != subjectID && subjectID != "admin" { + return UserKeyMeta{}, fmt.Errorf("key %q not found", keyID) + } + 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) + } + if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{ + EventID: generateKeyID(), ActorSubjectID: subjectID, ActorRole: "user", + TargetKeyID: keyID, Action: "resume", Result: "success", Reason: "self service resume", + }); err != nil { + return fmt.Errorf("audit resume key: %w", err) + } + return nil + }) + if err != nil { + return UserKeyMeta{}, err + } + return UserKeyMeta{KeyID: keyID, MaskedPreview: rec.MaskedPreview, AdminStatus: "active"}, nil }, deleteFn: func(ctx context.Context, keyID, subjectID string) error { store, err := sqlite.Open(ctx, sqliteDSN) @@ -156,7 +375,35 @@ func buildUserKeyHandler(sqliteDSN string) *UserKeyHandler { return fmt.Errorf("open store: %w", err) } defer store.Close() - return store.UserKeys().UpdateStatus(ctx, keyID, "retired") + + rec, err := store.UserKeys().GetByID(ctx, keyID) + if err != nil { + return fmt.Errorf("get key: %w", err) + } + if rec.OwnerSubjectID != subjectID && subjectID != "admin" { + return fmt.Errorf("key %q not found", keyID) + } + err = store.WithTx(ctx, func(q *sqlite.Queries) error { + if err := q.UserKeys.UpdateStatus(ctx, keyID, "retired"); err != nil { + if strings.Contains(err.Error(), sql.ErrNoRows.Error()) { + return fmt.Errorf("key %q not found", keyID) + } + return fmt.Errorf("retire key: %w", err) + } + if _, err := q.UserKeyAuditEvents.Create(ctx, sqlite.UserKeyAuditEvent{ + EventID: generateKeyID(), ActorSubjectID: subjectID, ActorRole: "user", + TargetKeyID: keyID, Action: "delete", Result: "success", Reason: "self service retire", + }); err != nil { + return fmt.Errorf("audit retire key: %w", err) + } + return nil + }) + return err }, } } + +func sha256Hex(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/app/key_self_service_test.go b/internal/app/key_self_service_test.go new file mode 100644 index 00000000..0e6c402d --- /dev/null +++ b/internal/app/key_self_service_test.go @@ -0,0 +1,152 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +func makeCreateBody(groupID, displayName string, models []string) io.Reader { + b, _ := json.Marshal(map[string]any{ + "logical_group_id": groupID, + "display_name": displayName, + "allowed_models": models, + }) + return bytes.NewReader(b) +} + +func makeCreateRequest(t *testing.T, method, path string, body io.Reader) *http.Request { + t.Helper() + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/json") + return req +} + +func TestUserKeyAPIUsesPortalSubjectHeader(t *testing.T) { + t.Parallel() + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + + // Seed a logical group + route + host so resolveLogicalGroupHost succeeds + _, _ = store.Hosts().Create(context.Background(), sqlite.Host{ + HostID: "test-host", + BaseURL: "http://127.0.0.1:1", + HostVersion: "0.0.1", + CapabilityProbeJSON: "{}", + AuthType: "apikey", + AuthToken: "test-token", + }) + _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ + LogicalGroupID: "gpt-shared", + DisplayName: "GPT Shared", + Status: "active", + }) + _, _ = store.LogicalGroupRoutes().Create(context.Background(), sqlite.LogicalGroupRoute{ + RouteID: "test-route", + LogicalGroupID: "gpt-shared", + Name: "Test Route", + Status: "active", + ShadowHostID: "test-host", + ShadowGroupID: "999", + }) + + handler := NewAPIHandler("t", ActionSet{ + UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)), + }) + + req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("gpt-shared", "portal key", []string{"gpt-5.4"})) + req.Header.Set("X-Portal-Subject", "smoke-user") + resp := httptestRecorder(handler, req) + + // We expect 500 because test host is unreachable (port 1), but the important + // thing is the request decoded the subject header and reached the host resolution + // step (not 401 "user credentials required") + if resp.code == http.StatusUnauthorized || resp.code == http.StatusNotImplemented { + t.Fatalf("status code = %d, expected to pass auth layer", resp.code) + } + + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(resp.Body().Bytes(), &errResp); err == nil { + if strings.Contains(errResp.Error.Message, "POST /api/v1/auth/login") || + strings.Contains(errResp.Error.Message, "no such host") || + strings.Contains(errResp.Error.Message, "connect: connection refused") || + strings.Contains(errResp.Error.Message, "dial tcp") { + t.Logf("expected host-level error (not auth): code=%s msg=%s", errResp.Error.Code, errResp.Error.Message) + } else { + t.Logf("unexpected error shape: code=%s msg=%s", errResp.Error.Code, errResp.Error.Message) + } + } +} + +func TestUserKeyCreateRejectsMissingSubject(t *testing.T) { + t.Parallel() + handler := NewAPIHandler("t", ActionSet{ + UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t))), + }) + req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("gpt-shared", "portal key", nil)) + resp := httptestRecorder(handler, req) + if resp.code != http.StatusUnauthorized { + t.Fatalf("status code = %d, want 401", resp.code) + } +} + +func TestUserKeyCreateRejectsMissingGroup(t *testing.T) { + t.Parallel() + handler := NewAPIHandler("t", ActionSet{ + UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t))), + }) + body := bytes.NewReader([]byte(`{"display_name":"portal key"}`)) + req := makeCreateRequest(t, http.MethodPost, "/api/keys", body) + req.Header.Set("X-Portal-Subject", "smoke-user") + resp := httptestRecorder(handler, req) + if resp.code != http.StatusBadRequest { + t.Fatalf("status code = %d, want 400", resp.code) + } +} + +func TestUserKeyResetRejectsMissingSubject(t *testing.T) { + t.Parallel() + handler := NewAPIHandler("t", ActionSet{ + UserKeyHandler: buildUserKeyHandler(appTestDSN(t, openAppTestStore(t))), + }) + req := httptest.NewRequest(http.MethodPost, "/api/keys/key_123/reset", nil) + req.Header.Set("Content-Type", "application/json") + resp := httptestRecorder(handler, req) + if resp.code != http.StatusUnauthorized { + t.Fatalf("status code = %d, want 401", resp.code) + } +} + +func TestUserKeyRateLimitNoDB(t *testing.T) { + t.Parallel() + store := openAppTestStore(t) + defer closeAppTestStore(t, store) + _, _ = store.LogicalGroups().Create(context.Background(), sqlite.LogicalGroup{ + LogicalGroupID: "gpt-shared", + DisplayName: "GPT Shared", + Status: "active", + }) + + handler := NewAPIHandler("t", ActionSet{ + UserKeyHandler: buildUserKeyHandler(appTestDSN(t, store)), + }) + + req := makeCreateRequest(t, http.MethodPost, "/api/keys", makeCreateBody("gpt-shared", "rate-test", nil)) + req.Header.Set("X-Portal-Subject", "rate-user") + resp := httptestRecorder(handler, req) + if resp.code == http.StatusUnauthorized || resp.code == http.StatusNotImplemented { + t.Fatalf("status code = %d, expected to pass auth layer", resp.code) + } +} diff --git a/internal/store/migrations/0016_user_key_control_plane.sql b/internal/store/migrations/0016_user_key_control_plane.sql new file mode 100644 index 00000000..f5c06ee5 --- /dev/null +++ b/internal/store/migrations/0016_user_key_control_plane.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS user_key_audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT UNIQUE NOT NULL, + actor_subject_id TEXT NOT NULL, + actor_role TEXT NOT NULL CHECK (actor_role IN ('admin','user','system')), + target_key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('create','reset','pause','resume','delete')), + result TEXT NOT NULL CHECK (result IN ('success','denied','failed')), + reason TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); + +CREATE INDEX IF NOT EXISTS idx_user_key_audit_target_key_id ON user_key_audit_events(target_key_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_user_key_audit_actor_subject_id ON user_key_audit_events(actor_subject_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS subject_rate_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_id TEXT NOT NULL, + action TEXT NOT NULL, + window_start TEXT NOT NULL, + hit_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE(subject_id, action, window_start) +); + +CREATE INDEX IF NOT EXISTS idx_subject_rate_limits_lookup ON subject_rate_limits(subject_id, action, window_start); diff --git a/internal/store/sqlite/db.go b/internal/store/sqlite/db.go index 5fbe9232..12d93d04 100644 --- a/internal/store/sqlite/db.go +++ b/internal/store/sqlite/db.go @@ -42,6 +42,8 @@ type Queries struct { AccessClosures *AccessClosureRecordsRepo ReconcileRuns *ReconcileRunsRepo UserKeys *UserKeysRepo + UserKeyAuditEvents *UserKeyAuditEventsRepo + SubjectRateLimits *SubjectRateLimitsRepo } type DB struct { @@ -181,6 +183,14 @@ func (db *DB) UserKeys() *UserKeysRepo { return db.queries.UserKeys } +func (db *DB) UserKeyAuditEvents() *UserKeyAuditEventsRepo { + return db.queries.UserKeyAuditEvents +} + +func (db *DB) SubjectRateLimits() *SubjectRateLimitsRepo { + return db.queries.SubjectRateLimits +} + func (db *DB) WithTx(ctx context.Context, fn func(*Queries) error) error { tx, err := db.sqlDB.BeginTx(ctx, nil) if err != nil { @@ -228,6 +238,8 @@ func newQueries(db execQuerier) *Queries { AccessClosures: newAccessClosureRecordsRepo(db), ReconcileRuns: newReconcileRunsRepo(db), UserKeys: newUserKeysRepo(db), + UserKeyAuditEvents: newUserKeyAuditEventsRepo(db), + SubjectRateLimits: newSubjectRateLimitsRepo(db), } } diff --git a/internal/store/sqlite/subject_rate_limits_repo.go b/internal/store/sqlite/subject_rate_limits_repo.go new file mode 100644 index 00000000..9847390e --- /dev/null +++ b/internal/store/sqlite/subject_rate_limits_repo.go @@ -0,0 +1,59 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" +) + +type SubjectRateLimitWindow struct { + ID int64 `json:"-"` + SubjectID string `json:"subject_id"` + Action string `json:"action"` + WindowStart string `json:"window_start"` + HitCount int64 `json:"hit_count"` + UpdatedAt string `json:"updated_at"` +} + +type SubjectRateLimitsRepo struct { + db execQuerier +} + +func newSubjectRateLimitsRepo(db execQuerier) *SubjectRateLimitsRepo { + return &SubjectRateLimitsRepo{db: db} +} + +func (r *SubjectRateLimitsRepo) IncrementWindow(ctx context.Context, subjectID, action, windowStart string) (int64, error) { + subjectID = strings.TrimSpace(subjectID) + action = strings.ToLower(strings.TrimSpace(action)) + windowStart = strings.TrimSpace(windowStart) + if subjectID == "" { + return 0, fmt.Errorf("subject_id is required") + } + if action == "" { + return 0, fmt.Errorf("action is required") + } + if windowStart == "" { + return 0, fmt.Errorf("window_start is required") + } + _, err := r.db.ExecContext(ctx, `INSERT INTO subject_rate_limits (subject_id, action, window_start, hit_count, updated_at) + VALUES (?, ?, ?, 1, strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ON CONFLICT(subject_id, action, window_start) + DO UPDATE SET hit_count = hit_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')`, subjectID, action, windowStart) + if err != nil { + return 0, fmt.Errorf("increment subject rate limit %s/%s/%s: %w", subjectID, action, windowStart, err) + } + return r.GetCount(ctx, subjectID, action, windowStart) +} + +func (r *SubjectRateLimitsRepo) GetCount(ctx context.Context, subjectID, action, windowStart string) (int64, error) { + subjectID = strings.TrimSpace(subjectID) + action = strings.ToLower(strings.TrimSpace(action)) + windowStart = strings.TrimSpace(windowStart) + row := r.db.QueryRowContext(ctx, `SELECT hit_count FROM subject_rate_limits WHERE subject_id = ? AND action = ? AND window_start = ?`, subjectID, action, windowStart) + var count int64 + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("get subject rate limit %s/%s/%s: %w", subjectID, action, windowStart, err) + } + return count, nil +} diff --git a/internal/store/sqlite/user_key_audit_events_repo.go b/internal/store/sqlite/user_key_audit_events_repo.go new file mode 100644 index 00000000..dc793989 --- /dev/null +++ b/internal/store/sqlite/user_key_audit_events_repo.go @@ -0,0 +1,101 @@ +package sqlite + +import ( + "context" + "fmt" + "strings" +) + +type UserKeyAuditEvent struct { + ID int64 `json:"-"` + EventID string `json:"event_id"` + ActorSubjectID string `json:"actor_subject_id"` + ActorRole string `json:"actor_role"` + TargetKeyID string `json:"target_key_id"` + Action string `json:"action"` + Result string `json:"result"` + Reason string `json:"reason,omitempty"` + CreatedAt string `json:"created_at"` +} + +type UserKeyAuditEventsRepo struct { + db execQuerier +} + +func newUserKeyAuditEventsRepo(db execQuerier) *UserKeyAuditEventsRepo { + return &UserKeyAuditEventsRepo{db: db} +} + +func (r *UserKeyAuditEventsRepo) Create(ctx context.Context, row UserKeyAuditEvent) (int64, error) { + row, err := normalizeUserKeyAuditEvent(row) + if err != nil { + return 0, err + } + result, err := r.db.ExecContext(ctx, `INSERT INTO user_key_audit_events ( + event_id, actor_subject_id, actor_role, target_key_id, action, result, reason + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + row.EventID, row.ActorSubjectID, row.ActorRole, row.TargetKeyID, row.Action, row.Result, row.Reason, + ) + if err != nil { + return 0, fmt.Errorf("insert user key audit event %q: %w", row.EventID, err) + } + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("read inserted user key audit event id for %q: %w", row.EventID, err) + } + return id, nil +} + +func (r *UserKeyAuditEventsRepo) ListByTargetKeyID(ctx context.Context, keyID string, limit int) ([]UserKeyAuditEvent, error) { + keyID = strings.TrimSpace(keyID) + if keyID == "" { + return nil, fmt.Errorf("target_key_id is required") + } + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := r.db.QueryContext(ctx, `SELECT id, event_id, actor_subject_id, actor_role, target_key_id, action, result, reason, created_at + FROM user_key_audit_events WHERE target_key_id = ? ORDER BY id DESC LIMIT ?`, keyID, limit) + if err != nil { + return nil, fmt.Errorf("list user key audit events for %q: %w", keyID, err) + } + defer rows.Close() + items := make([]UserKeyAuditEvent, 0) + for rows.Next() { + var item UserKeyAuditEvent + if err := rows.Scan(&item.ID, &item.EventID, &item.ActorSubjectID, &item.ActorRole, &item.TargetKeyID, &item.Action, &item.Result, &item.Reason, &item.CreatedAt); err != nil { + return nil, fmt.Errorf("scan user key audit event: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate user key audit events: %w", err) + } + return items, nil +} + +func normalizeUserKeyAuditEvent(row UserKeyAuditEvent) (UserKeyAuditEvent, error) { + row.EventID = strings.TrimSpace(row.EventID) + row.ActorSubjectID = strings.TrimSpace(row.ActorSubjectID) + row.ActorRole = strings.ToLower(strings.TrimSpace(row.ActorRole)) + row.TargetKeyID = strings.TrimSpace(row.TargetKeyID) + row.Action = strings.ToLower(strings.TrimSpace(row.Action)) + row.Result = strings.ToLower(strings.TrimSpace(row.Result)) + row.Reason = strings.TrimSpace(row.Reason) + + switch { + case row.EventID == "": + return UserKeyAuditEvent{}, fmt.Errorf("event_id is required") + case row.ActorSubjectID == "": + return UserKeyAuditEvent{}, fmt.Errorf("actor_subject_id is required") + case row.ActorRole != "admin" && row.ActorRole != "user" && row.ActorRole != "system": + return UserKeyAuditEvent{}, fmt.Errorf("invalid actor_role: %s", row.ActorRole) + case row.TargetKeyID == "": + return UserKeyAuditEvent{}, fmt.Errorf("target_key_id is required") + case row.Action != "create" && row.Action != "reset" && row.Action != "pause" && row.Action != "resume" && row.Action != "delete": + return UserKeyAuditEvent{}, fmt.Errorf("invalid action: %s", row.Action) + case row.Result != "success" && row.Result != "denied" && row.Result != "failed": + return UserKeyAuditEvent{}, fmt.Errorf("invalid result: %s", row.Result) + } + return row, nil +} diff --git a/internal/store/sqlite/user_key_control_plane_repo_test.go b/internal/store/sqlite/user_key_control_plane_repo_test.go new file mode 100644 index 00000000..8f57e418 --- /dev/null +++ b/internal/store/sqlite/user_key_control_plane_repo_test.go @@ -0,0 +1,101 @@ +package sqlite + +import ( + "context" + "strings" + "testing" +) + +func TestUserKeyAuditEventsRepoCreateList(t *testing.T) { + store := openTestDB(t) + ctx := context.Background() + + if _, err := store.UserKeyAuditEvents().Create(ctx, UserKeyAuditEvent{ + EventID: "evt_create_001", + ActorSubjectID: "user_long", + ActorRole: "user", + TargetKeyID: "key_test_001", + Action: "create", + Result: "success", + Reason: "self service create", + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := store.UserKeyAuditEvents().Create(ctx, UserKeyAuditEvent{ + EventID: "evt_reset_001", + ActorSubjectID: "user_long", + ActorRole: "user", + TargetKeyID: "key_test_001", + Action: "reset", + Result: "success", + Reason: "rotate key", + }); err != nil { + t.Fatalf("Create(reset) error = %v", err) + } + + events, err := store.UserKeyAuditEvents().ListByTargetKeyID(ctx, "key_test_001", 10) + if err != nil { + t.Fatalf("ListByTargetKeyID() error = %v", err) + } + if len(events) != 2 { + t.Fatalf("ListByTargetKeyID() len = %d, want 2", len(events)) + } + if events[0].Action != "reset" || events[1].Action != "create" { + t.Fatalf("ListByTargetKeyID() order = %+v, want reset then create", events) + } +} + +func TestSubjectRateLimitsRepoIncrementWindow(t *testing.T) { + store := openTestDB(t) + ctx := context.Background() + + count1, err := store.SubjectRateLimits().IncrementWindow(ctx, "user_long", "create", "2026-06-05T14:00:00Z") + if err != nil { + t.Fatalf("IncrementWindow() first error = %v", err) + } + count2, err := store.SubjectRateLimits().IncrementWindow(ctx, "user_long", "create", "2026-06-05T14:00:00Z") + if err != nil { + t.Fatalf("IncrementWindow() second error = %v", err) + } + count3, err := store.SubjectRateLimits().IncrementWindow(ctx, "user_long", "reset", "2026-06-05T00:00:00Z") + if err != nil { + t.Fatalf("IncrementWindow(reset) error = %v", err) + } + if count1 != 1 || count2 != 2 || count3 != 1 { + t.Fatalf("counts = (%d,%d,%d), want (1,2,1)", count1, count2, count3) + } +} + +func TestUserKeysRepoUpdateSecret(t *testing.T) { + store := openTestDB(t) + ctx := context.Background() + + _, err := store.UserKeys().Create(ctx, UserKeyRecord{ + KeyID: "key_rotate_001", + OwnerSubjectID: "user_long", + KeyFingerprint: "sha256:old", + MaskedPreview: "sk-****old1", + DisplayName: "rotate me", + LogicalGroupID: "gpt-shared", + AllowedModels: []string{"gpt-5.4"}, + AdminStatus: "active", + QuotaStatus: "ok", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if err := store.UserKeys().UpdateSecret(ctx, "key_rotate_001", "sha256:new", "sk-****new1", "active"); err != nil { + t.Fatalf("UpdateSecret() error = %v", err) + } + key, err := store.UserKeys().GetByID(ctx, "key_rotate_001") + if err != nil { + t.Fatalf("GetByID() error = %v", err) + } + if key.KeyFingerprint != "sha256:new" || key.MaskedPreview != "sk-****new1" || key.AdminStatus != "active" { + t.Fatalf("updated key = %+v, want new fingerprint/mask/status", key) + } + if strings.TrimSpace(key.UpdatedAt) == "" { + t.Fatalf("UpdatedAt = %q, want non-empty", key.UpdatedAt) + } +} diff --git a/internal/store/sqlite/user_keys_repo.go b/internal/store/sqlite/user_keys_repo.go index 61fbdfdd..4bd6635d 100644 --- a/internal/store/sqlite/user_keys_repo.go +++ b/internal/store/sqlite/user_keys_repo.go @@ -128,17 +128,62 @@ func (r *UserKeysRepo) UpdateStatus(ctx context.Context, keyID string, adminStat if !valid[status] { return fmt.Errorf("invalid admin_status: %s", adminStatus) } - _, err := r.db.ExecContext(ctx, + result, err := r.db.ExecContext(ctx, `UPDATE user_keys SET admin_status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE key_id = ?`, status, keyID) if err != nil { return fmt.Errorf("update user_key status: %w", err) } + rowsAffected, rowsErr := result.RowsAffected() + if rowsErr == nil && rowsAffected == 0 { + return fmt.Errorf("update user_key status: %w", sql.ErrNoRows) + } + return nil +} + +func (r *UserKeysRepo) UpdateSecret(ctx context.Context, keyID, fingerprint, maskedPreview, adminStatus string) error { + keyID = strings.TrimSpace(keyID) + fingerprint = strings.TrimSpace(fingerprint) + maskedPreview = strings.TrimSpace(maskedPreview) + adminStatus = strings.ToLower(strings.TrimSpace(adminStatus)) + if keyID == "" { + return fmt.Errorf("key_id is required") + } + if fingerprint == "" { + return fmt.Errorf("key_fingerprint is required") + } + if maskedPreview == "" { + return fmt.Errorf("masked_preview is required") + } + valid := map[string]bool{"active": true, "paused": true, "disabled": true, "retired": true} + if !valid[adminStatus] { + return fmt.Errorf("invalid admin_status: %s", adminStatus) + } + result, err := r.db.ExecContext(ctx, + `UPDATE user_keys + SET key_fingerprint = ?, masked_preview = ?, admin_status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') + WHERE key_id = ?`, + fingerprint, maskedPreview, adminStatus, keyID, + ) + if err != nil { + return fmt.Errorf("update user_key secret: %w", err) + } + rowsAffected, rowsErr := result.RowsAffected() + if rowsErr == nil && rowsAffected == 0 { + return fmt.Errorf("update user_key secret: %w", sql.ErrNoRows) + } return nil } func (r *UserKeysRepo) TouchLastUsed(ctx context.Context, keyID string) error { - _, err := r.db.ExecContext(ctx, + result, err := r.db.ExecContext(ctx, `UPDATE user_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE key_id = ?`, keyID) - return err + if err != nil { + return err + } + rowsAffected, rowsErr := result.RowsAffected() + if rowsErr == nil && rowsAffected == 0 { + return fmt.Errorf("touch user key last_used_at: %w", sql.ErrNoRows) + } + return nil } diff --git a/scripts/acceptance/verify_user_key_self_service.sh b/scripts/acceptance/verify_user_key_self_service.sh index 238ad3f8..3f13d89f 100755 --- a/scripts/acceptance/verify_user_key_self_service.sh +++ b/scripts/acceptance/verify_user_key_self_service.sh @@ -1,110 +1,236 @@ #!/usr/bin/env bash -# verify_user_key_self_service.sh — 用户 key 自助验收入口 -# -# 本脚本为 Phase 0 skeleton。验收逻辑在 Phase 3(vNext.2)实现。 -# 当前仅验证环境就绪与目录规范。 -# -# 使用方式: -# bash scripts/acceptance/verify_user_key_self_service.sh --help -# bash scripts/acceptance/verify_user_key_self_service.sh [--env-check] - set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -TS="$(date +%Y%m%d_%H%M%S)" +TS="${TS:-$(date +%Y%m%d_%H%M%S)}" ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT_DIR/artifacts/user-key-self-service/${TS}}" - CRM_BASE="${CRM_BASE:-https://sub.tksea.top/portal-admin-api}" +USER_CHAT_BASE="${USER_CHAT_BASE:-}" +CHAT_MODEL="${CHAT_MODEL:-gpt-5.4}" +USER_SUBJECT_ID="${USER_SUBJECT_ID:-}" +USER_AUTH_TOKEN="${USER_AUTH_TOKEN:-}" -# --- helpers --- -die() { echo "FATAL: $*" >&2; exit 1; } -info() { echo "INFO: $*"; } -ok() { echo "OK: $*"; } +mkdir -p "$ARTIFACT_DIR" -cmd_help() { - cat <&2; } +die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } -Phase 0 skeleton — user key self-service acceptance script. +usage() { + cat <<'EOF' +usage: verify_user_key_self_service.sh [--help|--env-check|--run] -options: - --help 显示此帮助 - --env-check 验证环境变量与基本可达性 +Modes: + --help 显示帮助 + --env-check 仅检查 CRM / chat 入口与认证前置 + --run 执行真实 user-key 闭环:create -> list -> get -> reset -> chat -当前状态: - 此脚本为 vNext.1 Phase 0 骨架。验收逻辑将在 vNext.2 (Phase 3) 实现。 - vNext.1 目标用户 key 自助已明确推迟到 vNext.2。 +Required env for --run: + CRM_BASE CRM API base, e.g. https://sub.tksea.top/portal-admin-api + USER_CHAT_BASE 最终 user-key 调用入口 base, e.g. https://sub.tksea.top + CHAT_MODEL chat 模型名,default: gpt-5.4 -环境变量: - CRM_BASE CRM API 基础 URL (default: https://sub.tksea.top/portal-admin-api) - CRM_ADMIN_TOKEN Admin token(可选,env-check 用) +Authentication for /api/keys endpoints (choose one): + USER_SUBJECT_ID 通过 X-Portal-Subject 头注入 subject(联合部署/受信入口) + USER_AUTH_TOKEN 通过 Authorization: Bearer 走用户链路 -验收范围 (vNext.2): - - 用户 key 自助申请 - - key 首次回显与仅首次显示明文 - - key 状态展示(active/paused/exhausted) - - 用户首次 POST /v1/chat/completions = 200 闭环 - -输出: +Artifacts: artifacts/user-key-self-service// -HELP - exit 0 + - 00-env.json + - 10-create.headers.txt / 10-create.body.json + - 11-list.headers.txt / 11-list.body.json + - 12-get.headers.txt / 12-get.body.json + - 13-reset.headers.txt / 13-reset.body.json + - 20-chat.headers.txt / 20-chat.body.json + - 99-summary.json +EOF +} + +build_auth_args() { + if [[ -n "$USER_AUTH_TOKEN" ]]; then + printf '%s\n' "-H" "Authorization: Bearer $USER_AUTH_TOKEN" + return 0 + fi + if [[ -n "$USER_SUBJECT_ID" ]]; then + printf '%s\n' "-H" "X-Portal-Subject: $USER_SUBJECT_ID" + return 0 + fi + return 1 +} + +curl_json_with_capture() { + local method="$1" + local url="$2" + local headers_file="$3" + local body_file="$4" + local payload="${5:-}" + local -a args + args=(curl -sS --noproxy '*' -D "$headers_file" -o "$body_file" -X "$method") + while IFS= read -r line; do + args+=("$line") + done < <(build_auth_args) + if [[ -n "$payload" ]]; then + args+=(-H 'Content-Type: application/json' -d "$payload") + fi + args+=("$url") + "${args[@]}" +} + +curl_chat_with_capture() { + local plaintext_key="$1" + local payload="$2" + local headers_file="$3" + local body_file="$4" + curl -sS --noproxy '*' \ + -D "$headers_file" \ + -o "$body_file" \ + -H "Authorization: Bearer $plaintext_key" \ + -H 'Content-Type: application/json' \ + -X POST \ + -d "$payload" \ + "${USER_CHAT_BASE%/}/v1/chat/completions" +} + +extract_http_code() { + local headers_file="$1" + awk 'toupper($1) ~ /^HTTP\// { code=$2 } END { print code }' "$headers_file" +} + +json_get() { + local file="$1" + local expr="$2" + python3 - "$file" "$expr" <<'PY' +import json, sys +path, expr = sys.argv[1:3] +value = json.load(open(path, 'r', encoding='utf-8')) +for part in expr.split('.'): + if isinstance(value, dict): + value = value.get(part) + else: + raise SystemExit(2) +print("" if value is None else value) +PY } cmd_env_check() { - info "env-check mode" - mkdir -p "$ARTIFACT_DIR" - - if [[ -z "${CRM_BASE}" ]]; then - warn "CRM_BASE is empty" + local crm_health="unreachable" + if crm_health=$(curl -sS --noproxy '*' "${CRM_BASE%/}/healthz" 2>/dev/null); then + : else - ok "CRM_BASE=${CRM_BASE}" + crm_health="unreachable" fi - - if [[ -n "${CRM_ADMIN_TOKEN:-}" ]]; then - ok "CRM_ADMIN_TOKEN is set" - local whoami - whoami="$(curl -sS --noproxy '*' -H "Authorization: Bearer $CRM_ADMIN_TOKEN" "${CRM_BASE}/api/admin/session" 2>/dev/null)" || true - if echo "${whoami}" | python3 -c "import sys,json; d=json.load(sys.stdin); d.get('authenticated',False) or d.get('username','')" 2>/dev/null; then - ok "Admin session: valid" + local chat_health="unset" + if [[ -n "$USER_CHAT_BASE" ]]; then + if chat_health=$(curl -sS --noproxy '*' "${USER_CHAT_BASE%/}/healthz" 2>/dev/null); then + : else - warn "Admin session: invalid. Phase 3 will establish login flow." + chat_health="unreachable" fi - else - info "CRM_ADMIN_TOKEN not set — skipped (Phase 3 will implement login)" fi - - # Check portal-admin-api reachability - local health - health="$(curl -sS --noproxy '*' "${CRM_BASE}/healthz" 2>/dev/null)" || true - if [[ "${health}" == "ok" ]]; then - ok "CRM health: OK" - else - warn "CRM health: ${health:-unreachable}" - fi - - # Write env-check summary - local summary_file="$ARTIFACT_DIR/env-check-summary.json" - python3 -c " -import json, sys, datetime, os -d = { - 'timestamp': datetime.datetime.now().isoformat(), - 'mode': 'env_check', - 'crm_base': os.environ.get('CRM_BASE', ''), - 'crm_reachable': '${health:-}' == 'ok', - 'admin_token_set': bool(os.environ.get('CRM_ADMIN_TOKEN', '')), - 'phase': 'skeleton', - 'note': 'Full verification deferred to vNext.2 (Phase 3)' + OUT_PATH="$ARTIFACT_DIR/00-env.json" \ + CRM_BASE_PY="$CRM_BASE" \ + CRM_HEALTH="$crm_health" \ + USER_CHAT_BASE_PY="$USER_CHAT_BASE" \ + CHAT_HEALTH="$chat_health" \ + HAS_SUBJECT_ID="$USER_SUBJECT_ID" \ + HAS_AUTH_TOKEN="$USER_AUTH_TOKEN" \ + python3 - <<'PY' +import json, os +out = { + "crm_base": os.environ["CRM_BASE_PY"], + "crm_health": os.environ["CRM_HEALTH"], + "user_chat_base": os.environ["USER_CHAT_BASE_PY"], + "user_chat_health": os.environ["CHAT_HEALTH"], + "has_user_subject_id": bool(os.environ["HAS_SUBJECT_ID"]), + "has_user_auth_token": bool(os.environ["HAS_AUTH_TOKEN"]), } -with open(sys.argv[1], 'w') as f: - json.dump(d, f, ensure_ascii=False, indent=2) -" "$summary_file" - ok "env-check summary: $summary_file" +with open(os.environ["OUT_PATH"], "w", encoding="utf-8") as fh: + json.dump(out, fh, ensure_ascii=False, indent=2) +PY + if [[ "$crm_health" == "ok" ]]; then ok "CRM healthz=ok"; else warn "CRM healthz=$crm_health"; fi + if [[ -n "$USER_CHAT_BASE" ]]; then info "user chat health=$chat_health"; fi + ok "env summary: $ARTIFACT_DIR/00-env.json" +} + +cmd_run() { + cmd_env_check + [[ -n "$USER_CHAT_BASE" ]] || die "USER_CHAT_BASE is required for --run" + if ! build_auth_args >/dev/null; then + die "set USER_SUBJECT_ID or USER_AUTH_TOKEN for /api/keys authentication" + fi + + local create_payload create_code key_id plaintext_key masked_preview create_body + create_payload='{"logical_group_id":"gpt-shared","display_name":"acceptance-key","allowed_models":["'"$CHAT_MODEL"'"]}' + curl_json_with_capture POST "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/10-create.headers.txt" "$ARTIFACT_DIR/10-create.body.json" "$create_payload" >/dev/null + create_code="$(extract_http_code "$ARTIFACT_DIR/10-create.headers.txt")" + [[ "$create_code" == "201" ]] || die "create key failed: HTTP $create_code" + key_id="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.key_id')" + plaintext_key="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'plaintext_key')" + masked_preview="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.masked_preview')" + [[ -n "$key_id" && -n "$plaintext_key" ]] || die "create key response missing key_id/plaintext_key" + ok "create key -> HTTP 201, key_id=$key_id" + + curl_json_with_capture GET "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/11-list.headers.txt" "$ARTIFACT_DIR/11-list.body.json" >/dev/null + [[ "$(extract_http_code "$ARTIFACT_DIR/11-list.headers.txt")" == "200" ]] || die "list keys failed" + ok "list keys -> HTTP 200" + + curl_json_with_capture GET "${CRM_BASE%/}/api/keys/${key_id}" "$ARTIFACT_DIR/12-get.headers.txt" "$ARTIFACT_DIR/12-get.body.json" >/dev/null + [[ "$(extract_http_code "$ARTIFACT_DIR/12-get.headers.txt")" == "200" ]] || die "get key failed" + ok "get key -> HTTP 200" + + curl_json_with_capture POST "${CRM_BASE%/}/api/keys/${key_id}/reset" "$ARTIFACT_DIR/13-reset.headers.txt" "$ARTIFACT_DIR/13-reset.body.json" '{}' >/dev/null + [[ "$(extract_http_code "$ARTIFACT_DIR/13-reset.headers.txt")" == "200" ]] || die "reset key failed" + plaintext_key="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'plaintext_key')" + masked_preview="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'masked_preview')" + [[ -n "$plaintext_key" && -n "$masked_preview" ]] || die "reset response missing plaintext_key/masked_preview" + ok "reset key -> HTTP 200" + + local chat_payload chat_code + chat_payload='{"model":"'"$CHAT_MODEL"'","messages":[{"role":"user","content":"ping"}],"max_tokens":16,"temperature":0}' + curl_chat_with_capture "$plaintext_key" "$chat_payload" "$ARTIFACT_DIR/20-chat.headers.txt" "$ARTIFACT_DIR/20-chat.body.json" >/dev/null + chat_code="$(extract_http_code "$ARTIFACT_DIR/20-chat.headers.txt")" + [[ "$chat_code" == "200" ]] || die "user chat failed: HTTP $chat_code" + ok "user chat -> HTTP 200" + + python3 - "$ARTIFACT_DIR/99-summary.json" <&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "$haystack" != *"$needle"* ]]; then + fail "expected to find [$needle] in [$haystack]" + fi +} + +[[ -f "$SCRIPT" ]] || fail "missing $SCRIPT" + +help_output="$(bash "$SCRIPT" --help)" +assert_contains "$help_output" "verify_user_key_self_service.sh" +assert_contains "$help_output" "--env-check" +assert_contains "$help_output" "--run" +assert_contains "$help_output" "USER_CHAT_BASE" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT +fakebin="$tmpdir/bin" +mkdir -p "$fakebin" +cat > "$fakebin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +url="${@: -1}" +case "$url" in + https://crm.example.com/healthz) + printf 'ok' + ;; + https://chat.example.com/healthz) + printf 'Sub2APIok' + ;; + *) + printf '{}' + ;; +esac +EOF +chmod +x "$fakebin/curl" + +env_output="$(PATH="$fakebin:$PATH" ARTIFACT_DIR="$tmpdir/artifacts" CRM_BASE="https://crm.example.com" USER_CHAT_BASE="https://chat.example.com" bash "$SCRIPT" --env-check)" +assert_contains "$env_output" "CRM healthz=ok" +assert_contains "$env_output" "env summary" +[[ -f "$tmpdir/artifacts/00-env.json" ]] || fail "missing env summary json" +summary_text="$(cat "$tmpdir/artifacts/00-env.json")" +assert_contains "$summary_text" '"crm_health": "ok"' +assert_contains "$summary_text" '"user_chat_health": "&1)" +missing_auth_status=$? +set -e +if [[ $missing_auth_status -eq 0 ]]; then + fail "expected --run without auth to fail" +fi +assert_contains "$missing_auth_output" "set USER_SUBJECT_ID or USER_AUTH_TOKEN" + +echo "PASS: user key self-service script regression checks"