feat(vnext2): close user key self-service on real host
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 完成
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
152
internal/app/key_self_service_test.go
Normal file
152
internal/app/key_self_service_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
26
internal/store/migrations/0016_user_key_control_plane.sql
Normal file
26
internal/store/migrations/0016_user_key_control_plane.sql
Normal file
@@ -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);
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
internal/store/sqlite/subject_rate_limits_repo.go
Normal file
59
internal/store/sqlite/subject_rate_limits_repo.go
Normal file
@@ -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
|
||||
}
|
||||
101
internal/store/sqlite/user_key_audit_events_repo.go
Normal file
101
internal/store/sqlite/user_key_audit_events_repo.go
Normal file
@@ -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
|
||||
}
|
||||
101
internal/store/sqlite/user_key_control_plane_repo_test.go
Normal file
101
internal/store/sqlite/user_key_control_plane_repo_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <<HELP
|
||||
usage: $(basename "$0") [--help|--env-check]
|
||||
info() { printf 'INFO: %s\n' "$*"; }
|
||||
ok() { printf 'OK: %s\n' "$*"; }
|
||||
warn() { printf 'WARN: %s\n' "$*" >&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 <token> 走用户链路
|
||||
|
||||
验收范围 (vNext.2):
|
||||
- 用户 key 自助申请
|
||||
- key 首次回显与仅首次显示明文
|
||||
- key 状态展示(active/paused/exhausted)
|
||||
- 用户首次 POST /v1/chat/completions = 200 闭环
|
||||
|
||||
输出:
|
||||
Artifacts:
|
||||
artifacts/user-key-self-service/<timestamp>/
|
||||
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" <<PY
|
||||
import json
|
||||
summary = {
|
||||
"crm_base": ${CRM_BASE@Q},
|
||||
"user_chat_base": ${USER_CHAT_BASE@Q},
|
||||
"chat_model": ${CHAT_MODEL@Q},
|
||||
"key_id": ${key_id@Q},
|
||||
"masked_preview": ${masked_preview@Q},
|
||||
"create_http": int(${create_code@Q}),
|
||||
"list_http": 200,
|
||||
"get_http": 200,
|
||||
"reset_http": 200,
|
||||
"chat_http": 200,
|
||||
"checks": {
|
||||
"create_returns_plaintext_once": True,
|
||||
"list_returns_200": True,
|
||||
"get_returns_200": True,
|
||||
"reset_returns_new_plaintext": True,
|
||||
"user_chat_200": True,
|
||||
}
|
||||
}
|
||||
json.dump(summary, open(${ARTIFACT_DIR@Q} + "/99-summary.json", "w"), ensure_ascii=False, indent=2)
|
||||
PY
|
||||
cat "$ARTIFACT_DIR/99-summary.json"
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
case "${1:---help}" in
|
||||
--help|-h) cmd_help ;;
|
||||
--env-check) cmd_env_check ;;
|
||||
*) cmd_help ;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
--env-check)
|
||||
cmd_env_check
|
||||
;;
|
||||
--run)
|
||||
cmd_run
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
67
scripts/test/test_user_key_self_service_script.sh
Executable file
67
scripts/test/test_user_key_self_service_script.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT_DIR/scripts/acceptance/verify_user_key_self_service.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&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 '<!doctype html><html lang="zh-CN"><head><title>Sub2API</title></head><body>ok</body></html>'
|
||||
;;
|
||||
*)
|
||||
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": "<!doctype html><html lang=\"zh-CN\"'
|
||||
|
||||
set +e
|
||||
missing_auth_output="$(PATH="$fakebin:$PATH" ARTIFACT_DIR="$tmpdir/run-no-auth" CRM_BASE="https://crm.example.com" USER_CHAT_BASE="https://chat.example.com" bash "$SCRIPT" --run 2>&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"
|
||||
Reference in New Issue
Block a user