现象
非 admin 用户(持有 canLoginWebUi 的 key)在「用户管理」页点击「新建」给自己创建 key 时,提交后 toast 报错,显示原始 i18n key 文案 errors.auth.forbidden(即页面上看到的 "errors auth forbidden"),无法创建 key。
admin 用户不受影响。
复现步骤
- 用一个
role=user、canLoginWebUi=true 的 key 登录 Web UI。
- 进入「用户管理」页(非 admin 视图)。
- 点击主「新建」按钮 —— 非 admin 时打开的是「给自己建 key」的 AddKeyDialog。
- 填写后提交。
- 期望:成功创建自己的 key;实际:toast 显示
errors.auth.forbidden,创建失败(HTTP 403)。
根因分析
这是「自助建 key 的写路径被 admin-only 路由拦截」的真实 403 bug,叠加一个错误文案显示 bug。
逐跳链路:
- 入口:
src/app/[locale]/dashboard/users/users-page-client.tsx:803-821 —— 非 admin 时主「新建」按钮打开 AddKeyDialog(userId=selfUser.id, isAdmin=false),这是有意提供的自助功能。
- 提交:
forms/add-key-form.tsx:79 -> addKey() -> src/lib/api-client/v1/actions/keys.ts:18 -> apiPost("/api/v1/users/${userId}/keys")。
- 被拦:该路由
src/app/api/v1/resources/keys/router.ts:90 写死 requireAuth("admin")。src/lib/api/v1/_shared/auth-middleware.ts:103-110 判定 session.user.role !== "admin" -> 返回 403 + errorCode: "auth.forbidden"。
- 关键矛盾:
addKey action 本身其实允许自助建 key(src/actions/keys.ts:128:session.user.id === data.userId 即放行),但请求在中间件就被拦掉,根本到不了 action。
- 文案降级:
fetcher.ts 封成 ApiError(errorCode:"auth.forbidden") -> _compat.toActionResult 原样带回 errorCode(未经过 errors.ts 的 API_ERROR_MESSAGE_KEYS 映射)-> add-key-form.tsx:100 getErrorMessage(tErrors, "auth.forbidden") -> t("auth.forbidden")。
- 缺键:
messages/*/errors.json 只有 PERMISSION_DENIED,没有 auth.forbidden 键 -> next-intl 渲染缺失键的兜底路径字符串 -> 用户看到 errors.auth.forbidden。
对照证据:读路径 getUsers()(src/lib/api-client/v1/actions/users.ts:42-50)对同样的 403 做了 :self 兜底(fallback 到 read-tier 的 /api/v1/users:self,users/router.ts:132),所以非 admin 能正常看到自己和自己的 key;但写路径 addKey() 没有任何 self 端点/兜底,于是自助建 key 必 403。
两个缺陷
- 功能根因:非 admin 自助建 key 缺少 self 写端点。UI 把自助建 key 暴露给非 admin,但
POST /api/v1/users/:userId/keys 是 admin-only;读有 :self 兜底、写没有。
- 显示缺陷:即便 403 合理,toast 也只显示原始 i18n key(
auth.forbidden 未映射成 PERMISSION_DENIED)。
引入提交
7e2a7223 — refactor: add v1 REST management OpenAPI (#1140)(2026-04-30)。
该提交把 add-key-form 的 addKey 从直接调用 Server Action(@/actions/keys,自带自助放行)改为走 admin-only 的 REST 路由 POST /api/v1/users/:userId/keys,并在同一提交里只给读路径加了 :self 兜底、漏给写路径加对应 self 端点/兜底,从而引入回归。后续 b5886630、0c587039 仅调整 :reveal/:enable/:renew 注册顺序,与本 bug 无关。
修复建议
方案 A(推荐,对齐读路径):
- 新增 read-tier 的自助建 key 端点(如
POST /api/v1/users:self/keys),内部强制 userId = session.user.id,复用现有 addKey action(已自带 self 校验)。
addKey() client 仿 getUsers(),isAdmin=false 时直接打 self 端点,或 isAdminForbidden 时回退。
叠加修复显示缺陷:
- 在
_compat.toActionResult 对 ApiError 统一用 getApiErrorMessageKey() 映射(auth.forbidden -> PERMISSION_DENIED)再回传,避免所有表单重复踩坑。
方案 B(不推荐):把 POST /users/:userId/keys 的 tier 从 admin 降为 read,靠 action 已有的 PERMISSION_DENIED 兜越权。改动更小,但会放行只读 token 写入,且需复核 GET .../keys 降级后的越权面,风险更高。
影响范围
- 所有非 admin(自助)用户均无法新建自己的 key。
- admin 不受影响。
现象
非 admin 用户(持有
canLoginWebUi的 key)在「用户管理」页点击「新建」给自己创建 key 时,提交后 toast 报错,显示原始 i18n key 文案errors.auth.forbidden(即页面上看到的 "errors auth forbidden"),无法创建 key。admin 用户不受影响。
复现步骤
role=user、canLoginWebUi=true的 key 登录 Web UI。errors.auth.forbidden,创建失败(HTTP 403)。根因分析
这是「自助建 key 的写路径被 admin-only 路由拦截」的真实 403 bug,叠加一个错误文案显示 bug。
逐跳链路:
src/app/[locale]/dashboard/users/users-page-client.tsx:803-821—— 非 admin 时主「新建」按钮打开AddKeyDialog(userId=selfUser.id,isAdmin=false),这是有意提供的自助功能。forms/add-key-form.tsx:79->addKey()->src/lib/api-client/v1/actions/keys.ts:18->apiPost("/api/v1/users/${userId}/keys")。src/app/api/v1/resources/keys/router.ts:90写死requireAuth("admin")。src/lib/api/v1/_shared/auth-middleware.ts:103-110判定session.user.role !== "admin"-> 返回 403 +errorCode: "auth.forbidden"。addKeyaction 本身其实允许自助建 key(src/actions/keys.ts:128:session.user.id === data.userId即放行),但请求在中间件就被拦掉,根本到不了 action。fetcher.ts封成ApiError(errorCode:"auth.forbidden")->_compat.toActionResult原样带回 errorCode(未经过errors.ts的API_ERROR_MESSAGE_KEYS映射)->add-key-form.tsx:100getErrorMessage(tErrors, "auth.forbidden")->t("auth.forbidden")。messages/*/errors.json只有PERMISSION_DENIED,没有auth.forbidden键 -> next-intl 渲染缺失键的兜底路径字符串 -> 用户看到errors.auth.forbidden。对照证据:读路径
getUsers()(src/lib/api-client/v1/actions/users.ts:42-50)对同样的 403 做了:self兜底(fallback 到 read-tier 的/api/v1/users:self,users/router.ts:132),所以非 admin 能正常看到自己和自己的 key;但写路径addKey()没有任何 self 端点/兜底,于是自助建 key 必 403。两个缺陷
POST /api/v1/users/:userId/keys是 admin-only;读有:self兜底、写没有。auth.forbidden未映射成PERMISSION_DENIED)。引入提交
7e2a7223—refactor: add v1 REST management OpenAPI (#1140)(2026-04-30)。该提交把
add-key-form的addKey从直接调用 Server Action(@/actions/keys,自带自助放行)改为走 admin-only 的 REST 路由POST /api/v1/users/:userId/keys,并在同一提交里只给读路径加了:self兜底、漏给写路径加对应 self 端点/兜底,从而引入回归。后续b5886630、0c587039仅调整:reveal/:enable/:renew注册顺序,与本 bug 无关。修复建议
方案 A(推荐,对齐读路径):
POST /api/v1/users:self/keys),内部强制userId = session.user.id,复用现有addKeyaction(已自带 self 校验)。addKey()client 仿getUsers(),isAdmin=false时直接打 self 端点,或isAdminForbidden时回退。叠加修复显示缺陷:
_compat.toActionResult对ApiError统一用getApiErrorMessageKey()映射(auth.forbidden->PERMISSION_DENIED)再回传,避免所有表单重复踩坑。方案 B(不推荐):把
POST /users/:userId/keys的 tier 从admin降为read,靠 action 已有的PERMISSION_DENIED兜越权。改动更小,但会放行只读 token 写入,且需复核GET .../keys降级后的越权面,风险更高。影响范围