feat(vnext2): close user key self-service on real host
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled

This commit is contained in:
phamnazage-jpg
2026-06-05 19:58:02 +08:00
parent 596a2a110c
commit 5b59ad7490
15 changed files with 1217 additions and 195 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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-4key self-service API + 用户首次调用 200 真实线上闭环。
- vNext.2 的 V2-5portal 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 |
| 至少一组真实 artifactupstream 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 |
| 至少一组真实 artifactupstream 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 3vNext.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 管理 UIV2-5
状态:未开始vNext.2 设计占位
状态:部分完成V2-4 已闭环V2-5 未完成
### Phase 4
### Phase 4vNext.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.2Phase 3portal key 自助 + 用户首次 200 闭环)
- V2-4 已真实闭环,可进入提交/推送
- 继续推进 V2-5portal key UI与 V3-1governance才能宣告全量 goal 完成

View File

@@ -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-4key 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/`

View File

@@ -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:

View File

@@ -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)

View File

@@ -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[:])
}

View 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)
}
}

View 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);

View File

@@ -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),
}
}

View 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
}

View 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
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -1,110 +1,236 @@
#!/usr/bin/env bash
# verify_user_key_self_service.sh — 用户 key 自助验收入口
#
# 本脚本为 Phase 0 skeleton。验收逻辑在 Phase 3vNext.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

View 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"