fix: enforce 1:1 user_id to API key mapping#738
Conversation
- Add UNIQUE INDEX on api_key_users(user_id) via migration 016 (SQLite + PG) - Add getByUserID method to apiKeyStoreBase, exposed via APIKeyUserStorer interface - HandleAPIKeyUserCreate: check user_id uniqueness, return 409 Conflict on duplicate - HandleAPIKeyUserUpdate: check uniqueness only when user_id changes, return 409 - Frontend: client-side pre-check before create + map server 409 to friendly message Closes #733
hotplex-ai
left a comment
There was a problem hiding this comment.
Code Review — hotplex-ai
Verdict: REQUEST_CHANGES | P0:0 P1:2 P2:3 P3:0
P1 — Must Fix
1. TOCTOU race + error swallowing + constraint violation→500 (internal/admin/apikey_handlers.go:21,286,386)
getByUserID 预检查用 existing, _ := 丢弃了所有错误。当 DB 连接出错时静默穿透到 create/update。更重要的是,并发请求可同时通过预检查,此时 DB unique constraint 触发——但 create handler 将所有错误统一返回 500 "create failed",update handler 走 respondStoreError 也无法区分唯一约束冲突。
同时,ErrUserIDExists 哨兵已声明但从未使用,无法桥接 DB 约束错误到 409。
建议修复:
getByUserID内部处理sql.ErrNoRows→ returnnil, nil- 预检查处处理非 nil error → 500
- 在
create/updatestore 方法中检测唯一约束错误(SQLite:sqlite3.ErrConstraintUnique,PG:duplicate key)→ wrap 为fmt.Errorf("...: %w", ErrUserIDExists) - handler 中
errors.Is(err, ErrUserIDExists)→ 409 - 或将预检查移入
writeMu.WithLock内部与 INSERT 原子化
2. Migration 未处理已有重复数据 (internal/session/sql/migrations/016_api_key_user_id_unique.sql:3, migrations-postgres/016_api_key_user_id_unique.pg.sql:3)
CREATE UNIQUE INDEX IF NOT EXISTS:
- SQLite: 遇到重复行时索引创建静默失败(索引不存在),后续插入仅靠应用层检查保护
- PG: 直接报错导致 migration 中断
建议在 Up 迁移中先添加数据清理步骤(去重保留最新/最旧行),再创建索引。
P2 — Suggestions
3. DRY violation (apikey_handlers.go:286-289, 385-390) — 提取 requireUniqueUserID(ctx, store, userID) 辅助函数。
4. Frontend formUserId.trim() 调用两次 (webchat/app/admin/api-keys/page.tsx:88,96) — trimmedId 已计算但未复用给 createAPIKey 调用。
5. Frontend 用原始字符串匹配后端错误 (page.tsx:106) — 应检查 HTTP status code 409 而非匹配 "user_id already exists" 字符串。
P1-1: Fix TOCTOU race + error swallowing + constraint→500 - getByUserID: handle sql.ErrNoRows → nil,nil; wrap other errors - Store create/update (SQLite+PG): detect IsUniqueViolation → wrap ErrUserIDExists - respondStoreError: ErrUserIDExists → 409 Conflict - Handlers: use requireUniqueUserID + respondStoreError for consistent error handling P1-2: Migration dedup before unique index - SQLite: DELETE WHERE rowid NOT IN (MAX rowid per user_id) - PG: DELETE WHERE id NOT IN (MAX id per user_id) P2-3: Extract requireUniqueUserID helper (DRY) P2-4: Frontend reuse trimmedId variable P2-5: Frontend check HTTP 409 status instead of matching error string - adminFetch: attach response.status to Error object
hotplex-ai
left a comment
There was a problem hiding this comment.
Code Review — hotplex-ai
Verdict: APPROVE | P0:0 P1:0 P2:1 P3:2
PR 实现了 user_id → API Key 的 1:1 唯一约束,采用应用层预检查 (requireUniqueUserID) + DB 唯一索引兜底的双层防护。
并发正确性已验证:SQLite 下 writeMu 序列化写入、PG 下 DB 约束直接拦截,requireUniqueUserID 的 check-then-act 竞态窗口能被 DB IsUniqueViolation 正确兜底返回 409,无数据完整性问题。IsUniqueViolation 对 SQLite(modernc)/PG(pgx) 双方言覆盖、接口满足性(pgStore 经由 apiKeyStoreBase 继承 getByUserID)、错误包装(ErrUserIDExists 哨兵 + %w)、ctx 传播均符合约定。可合并。
P2
- Migration 去重 DELETE 为不可逆数据破坏,无审计 (
internal/session/sql/migrations/016_api_key_user_id_unique.sql:4-7、migrations-postgres/016_api_key_user_id_unique.pg.sql:4-7) —DELETE ... WHERE id NOT IN (SELECT MAX(id) ... GROUP BY user_id)静默删除重复user_id的旧行(保留最新),被删 API key 立即失效。若生产环境存在重复数据,依赖这些 key 的客户端会突然认证失败,且无迁移日志可追溯。建议:迁移前先SELECT user_id, COUNT(*) FROM api_key_users GROUP BY user_id HAVING COUNT(*) > 1输出受影响行数到日志,或拆为独立的 advisory 迁移供运维人工确认。
P3
respondStoreError注释过期 (internal/admin/handlers.go:421-422) — 注释称 "Not-found errors return 404; all others are logged and return 500",但新增的ErrUserIDExists → 409分支已插在最前。建议补一句提及 409 conflict 路径,保持注释与行为一致。- swagger.json 手改项冗余 (
docs/swagger/swagger.json:105-110,275-280) —Makefile:349与 CI 均执行swag init从 Go 注解重新生成 swagger.json,手加的 409 条目会在下次生成时被覆盖。好在 handler 注解 (apikey_handlers.go:288,374) 已正确声明 409,生成结果一致,无功能影响。建议删除手改,依赖注解生成保持单一来源。
P2: Add advisory SELECT comment before migration dedup DELETE,
warn about key invalidation risk for production environments.
P3: Update respondStoreError comment to document 409 Conflict path.
hotplex-ai
left a comment
There was a problem hiding this comment.
Code Review — hotplex-ai
Verdict: ✅ APPROVE — 无 P0/P1。防御层设计合理(DB 唯一约束为主,应用层预检为辅),migration 包含 advisory 注释且 goose 默认事务包裹。
| 级别 | 数量 |
|---|---|
| P0 | 0 |
| P1 | 0 |
| P2 | 2 |
| P3 | 1 |
P2-1: Migration 去重 SQLite 用 rowid vs PG 用 id,语义微妙差异
internal/session/sql/migrations/016_api_key_user_id_unique.sql:8-9 vs migrations-postgres/...pg.sql:8-9
SQLite 版用 MAX(rowid) 选取保留行,PG 版用 MAX(id)。在正常 INSERT-only 场景下 rowid == id,但若历史上有过 DELETE + re-INSERT(rowid 可被 SQLite 复用),两者可能不一致。建议 SQLite 也用 MAX(id) 保持一致。
P2-2: requireUniqueUserID 对 PG 路径冗余
internal/admin/apikey_handlers.go:142-151
PG 的 writeMu 是 no-op(apikey_pg_store.go:26-27),因此 requireUniqueUserID 的 SELECT 预检和后续 INSERT 不在同一个原子作用域内。DB 唯一约束(IsUniqueViolation 检测)是实际的安全网,应用层预检仅提供更友好的错误消息。考虑对 PG 路径移除预检,减少一次 SELECT 往返。
P3-1: 409 响应为纯文本但 Swagger 声明 JSON ErrorResponse
internal/admin/handlers.go:425 + docs/swagger/swagger.json:105-110
respondStoreError 对 409 使用 http.Error(w, "user_id already exists", ...) 返回纯文本。Swagger 定义为 $ref: "#/definitions/admin.ErrorResponse"(JSON 对象)。与现有 404/500 错误响应风格一致(均为纯文本),但 Swagger 定义不准确。属于既有风格问题,非本 PR 引入。
Summary
Closes #733
Enforce 1:1 mapping between
user_idand API Key — each user can only have one API Key.Changes
DB Migration (016)
CREATE UNIQUE INDEX idx_api_key_users_user_id_unique ON api_key_users(user_id)Backend (
internal/admin/apikey_handlers.go)getByUserIDmethod onapiKeyStoreBase(inherited by both SQLite & PG stores)getByUserIDtoAPIKeyUserStorerinterfaceHandleAPIKeyUserCreate: checkuser_iduniqueness before insert →409 ConflictHandleAPIKeyUserUpdate: check uniqueness only whenuser_idchanges →409 Conflict@Failure 409to both endpointsFrontend (
webchat/app/admin/api-keys/page.tsx)user_idbefore sending request409message to friendly inline errorTest Plan
make check(quality + build + webchat build) passeduser_idtwice → second attempt returns 409user_idto an existing one → 409user_idto a new one → succeedsIF NOT EXISTS)Note
Pre-push hook failed on an unrelated lint issue in
codexcli/manager.go:892(sloglint snake_case). Not introduced by this PR.