fix(admin): fail safe across migration and auth boundaries

The recovery migration crossed the identity and RBAC ledgers without guarding schema prerequisites, while unknown configuration, provider, and database failures escaped the admin authorization boundary as 500s. Keep recovery in the DB ledger with explicit prerequisite no-ops, and sanitize unknown authorization failures to the existing 503 path.
This commit is contained in:
Jesse_Chen
2026-08-07 12:08:58 +08:00
parent 754505d40a
commit 66a6c96a5c
9 changed files with 314 additions and 110 deletions
+3 -3
View File
@@ -2394,9 +2394,9 @@
- 用户现象:用户完成后台域名登录后访问 `/`,浏览器报 `ERR_TOO_MANY_REDIRECTS`;未认证公开链仍正常表现为 `/` 308 到 `/admin`、再 307 到 `/login`、最终 200。
- 触发条件:已认证 self-hosted 用户通过身份 session,但 `admin_permission_keys` 没有返回 `admin.access`,后台 gate 产生 403;历史同域 fallback 将所有非 401 授权错误重定向到 `/`,而独立后台 Caddy 又将 `/` 永久重定向到 `/admin`
- 根因:第一层是双 host 发布后仍保留 BUG-123 的同域 `403/503 -> /` 行为,与 BUG-138 的后台根路径 `308 -> /admin` 组合成确定性循环。第二层是 `20260806010000_admin_rbac.sql` 的一次性 bootstrap 只捕获迁移执行当时已经是 identity admin 的用户;staging 脱敏聚合显示 `identity_admins=1``auth_users=1``bootstrap_eligible_admins=1`,但 `active_admin_users=0``owner_assignments=0``identity_admins_missing_rbac=1`,因此当前唯一 active identity admin 没有 RBAC Owner,真实授权结果为 403,而不是 cookie/host 隔离或数据库不可用。
- 修复:后台 layout 对 401 仍转 `/login`403 使用 Next.js forbidden interrupt 返回明确 403 页面;503 以 307 转到 admin layout 外的独立 `/admin-unavailable` route,由该 route 最终返回 503 和 `cache-control: no-store`,未知错误才继续抛出进入错误边界。后台根 route 对 403/503 直接返回对应状态和 `no-store` 文本响应,所有拒绝路径都不再导向 `/`Owner 恢复改为 Supabase RBAC migration 序列中的向前 migration:已有 active Owner no-op空白新库无账号时 no-op;已有人账号但无 active Owner 时,只接受恰好一个当前可登录(未封禁,或封禁截止时间已过)、已同步 `auth.users`、持久 identity role 包含 `admin`,且 `admin_users` 不存在或尚未 revoked 的候选。历史 revoked admin 明确排除,`admin_users` 冲突使用 `do nothing`,不得清除 `revoked_at/revoked_by`;零个或多个候选均以约束错误 fail closed。运行时授权继续只依赖数据库 RBAC,不读取 `ADMIN_EMAILS`,也不批量授权所有 identity admin。
- 验证:重定向合同覆盖 `401 -> /login`、403 forbidden、layout 503 只转 `/admin-unavailable` 以及 Caddy `/ -> /admin` 不成环;直接执行独立 route handler 验证最终响应为 503、`no-store`、无 `Location`真实 PostgreSQL fixture 验证空库 no-op、已过期封禁和带空格角色的单一同步候选可恢复、未同步或仍封禁账号不可恢复、已有 Owner 时第二个 identity admin 不获授权、revoked 历史管理员不会复活且撤销字段保持不变、两个候选和零候选均 fail closed。聚焦与完整测试、lint、TypeScript、构建结果见本次候选提交验证记录。
- 防复发:独立后台 host 的拒绝路径不得使用相对 `/` 作为逃生路由;401、403、503 必须分别保留认证、授权和服务故障语义,layout 不能把 503 吞成 500。依赖 RBAC schema 的恢复 migration 必须位于 Supabase migration 序列,不能放进可由 `MIGRATIONS_DIRECTORY=db/migrations` 独立执行的 identity 路径。一次性 RBAC bootstrap 后新增的初始管理员必须通过受约束向前 migration 或显式角色管理进入权限图;历史撤销是安全边界,禁止自动清除,也禁止用邮箱 allowlist 或“所有 identity admin”兜底。
- 修复:后台 layout 对 401 仍转 `/login`403 使用 Next.js forbidden interrupt 返回明确 403 页面;503 以 307 转到 admin layout 外的独立 `/admin-unavailable` route,由该 route 最终返回 503 和 `cache-control: no-store`。后台根 route 对 403/503 直接返回对应状态和 `no-store` 文本响应,所有拒绝路径都不再导向 `/`授权边界现将读取 self-hosted 配置、Host 判定、Better Auth/session、identity DB 与 RBAC DB 查询置于同一异常边界:既有 `AdminAuthorizationError` 原样保留,identity 401/403 继续映射为脱敏 401/403,Host 不匹配仍为 403,其余未知基础设施异常统一转换为 `后台服务暂时不可用` 503API `adminErrorResponse` 保留该最终状态。Owner 恢复 migration 保留在 `frontend/db/migrations`,但在任何表查询前用 `to_regclass`/`to_regprocedure` 检查 identity/auth 表、RBAC 表与关键函数;identity-only `MIGRATIONS_DIRECTORY=db/migrations` 缺少 RBAC 时安全 no-op 并正常记账,full staging 合并两目录后按文件名排序,在 `20260806010000_admin_rbac.sql` 之后执行恢复,而 Supabase-only/production migration 集合不包含该文件,不污染 production Supabase ledger。恢复语义仍为:已有 active Owner no-op真正空库 no-op;只有恰好一个当前可登录(未封禁,或封禁截止时间已过)、已同步 `auth.users`、持久 identity role 包含 `admin`,且 `admin_users` 不存在或尚未 revoked 的候选才恢复 Owner。历史 revoked admin 明确排除,`admin_users` 冲突使用 `do nothing`,不得清除 `revoked_at/revoked_by`;零个或多个候选均以约束错误 fail closed。运行时授权继续只依赖数据库 RBAC,不读取 `ADMIN_EMAILS`,也不批量授权所有 identity admin。
- 验证:重定向合同覆盖 `401 -> /login`、403 forbidden、嵌套页面 503 只转 `/admin-unavailable` 以及 Caddy `/ -> /admin` 不成环;直接执行独立 route handler 验证最终响应为 503、`no-store`、无 `Location`可执行授权单元测试覆盖配置 reader、Better Auth/identity reader 与 RBAC query 未知故障均脱敏为 503,既有授权异常与 identity 401/403 不变,并直接验证 `adminErrorResponse` 最终返回 503。真实 PostgreSQL fixture 验证 db-only identity migration 在 RBAC 缺失时安全 no-op、full 两目录流程执行恢复,以及空库 no-op、已过期封禁和带空格角色的单一同步候选可恢复、未同步或仍封禁账号不可恢复、已有 Owner 时第二个 identity admin 不获授权、revoked 历史管理员不会复活且撤销字段保持不变、两个候选和零候选均 fail closed。聚焦与完整测试、lint、TypeScript、构建结果见本次候选提交验证记录。
- 防复发:独立后台 host 的拒绝路径不得使用相对 `/` 作为逃生路由;401、403、503 必须分别保留认证、授权和服务故障语义,layout 不能把 503 吞成 500,授权依赖的未知配置/provider/数据库错误也不得泄露或退化为 500。跨 identity 与 RBAC 的恢复 migration 必须留在 DB migration ledger,并以显式 schema/function 前置检查兼容 identity-only no-op;不能放入 production 使用的 Supabase-only ledger。一次性 RBAC bootstrap 后新增的初始管理员必须通过受约束向前 migration 或显式角色管理进入权限图;历史撤销是安全边界,禁止自动清除,也禁止用邮箱 allowlist 或“所有 identity admin”兜底。
- 相关记录:BUG-123、BUG-134、BUG-138
- 复发自:BUG-123
- 修复版本:本次 staging admin redirect/RBAC recovery 候选提交