Skip to content

fix: enforce 1:1 user_id to API key mapping#738

Open
hrygo wants to merge 3 commits into
mainfrom
fix/issue-733-api-key-unique-user
Open

fix: enforce 1:1 user_id to API key mapping#738
hrygo wants to merge 3 commits into
mainfrom
fix/issue-733-api-key-unique-user

Conversation

@hrygo

@hrygo hrygo commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #733

Enforce 1:1 mapping between user_id and API Key — each user can only have one API Key.

Changes

DB Migration (016)

  • SQLite: CREATE UNIQUE INDEX idx_api_key_users_user_id_unique ON api_key_users(user_id)
  • PG: Same unique index with proper quoting

Backend (internal/admin/apikey_handlers.go)

  • Add getByUserID method on apiKeyStoreBase (inherited by both SQLite & PG stores)
  • Add getByUserID to APIKeyUserStorer interface
  • HandleAPIKeyUserCreate: check user_id uniqueness before insert → 409 Conflict
  • HandleAPIKeyUserUpdate: check uniqueness only when user_id changes → 409 Conflict
  • Swagger: add @Failure 409 to both endpoints

Frontend (webchat/app/admin/api-keys/page.tsx)

  • Client-side pre-check: reject duplicate user_id before sending request
  • Map server 409 message to friendly inline error

Test Plan

  • make check (quality + build + webchat build) passed
  • Create API Key with same user_id twice → second attempt returns 409
  • Update API Key user_id to an existing one → 409
  • Update API Key user_id to a new one → succeeds
  • Migration runs cleanly on existing DB (index is IF 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.

- 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 hotplex-ai left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 → return nil, nil
  • 预检查处处理非 nil error → 500
  • create/update store 方法中检测唯一约束错误(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
hotplex-ai previously approved these changes Jun 12, 2026

@hotplex-ai hotplex-ai left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-7migrations-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 hotplex-ai left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 引入。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin UI: 允许为同一 user_id 创建多个 API Key,缺乏唯一性约束

2 participants