diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 48d929df..b4455892 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 和未知错误继续抛出进入错误边界;后台根 route 对 403/503 直接返回对应状态和 `no-store` 文本响应,绝不再导向 `/`。新增向前 migration:已有 active Owner 时 no-op;空白新库无账号时 no-op;已有人账号但无 active Owner 时,只接受恰好一个当前可登录(未封禁,或封禁截止时间已过)、已同步 `auth.users` 且持久 identity role 包含 `admin` 的候选,将该单一用户恢复为 active `admin_users` + Owner;零个或多个候选均以约束错误 fail closed。运行时授权继续只依赖数据库 RBAC,不读取 `ADMIN_EMAILS`,也不批量授权所有 identity admin。 -- 验证:重定向合同先在旧实现上失败,修复后覆盖 `401 -> /login`、403 forbidden、503 原状态响应以及 Caddy `/ -> /admin` 不成环;真实 PostgreSQL fixture 验证空库 no-op、已过期封禁和带空格角色的单一同步候选可恢复、未同步或仍封禁账号不可恢复、已有 Owner 时第二个 identity admin 不获授权、两个候选和零候选均 fail closed。聚焦测试、lint、TypeScript、构建结果见本次候选提交验证记录。 -- 防复发:独立后台 host 的拒绝路径不得使用相对 `/` 作为逃生路由;401、403、503 必须分别保留认证、授权和服务故障语义。一次性 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` 文本响应,所有拒绝路径都不再导向 `/`。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”兜底。 - 相关记录:BUG-123、BUG-134、BUG-138 - 复发自:BUG-123 - 修复版本:本次 staging admin redirect/RBAC recovery 候选提交 diff --git a/frontend/src/app/admin-unavailable/route.ts b/frontend/src/app/admin-unavailable/route.ts new file mode 100644 index 00000000..c0923647 --- /dev/null +++ b/frontend/src/app/admin-unavailable/route.ts @@ -0,0 +1,9 @@ +export function GET() { + return new Response("后台服务暂时不可用", { + status: 503, + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }, + }); +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index 7a990dcb..dfa8ae98 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -17,6 +17,7 @@ export default async function AdminLayout({ children }: { children: ReactNode }) const failure = resolveAdminPageAccessFailure(error.status); if (failure.kind === "login") redirect(failure.location); if (failure.kind === "forbidden") forbidden(); + if (failure.kind === "unavailable") redirect(failure.location); } throw error; } diff --git a/frontend/src/lib/admin/page-access.ts b/frontend/src/lib/admin/page-access.ts index 216ec938..00bdb61e 100644 --- a/frontend/src/lib/admin/page-access.ts +++ b/frontend/src/lib/admin/page-access.ts @@ -3,7 +3,7 @@ export type AdminPageAuthorizationStatus = 401 | 403 | 503; export type AdminPageAccessFailure = | { kind: "login"; location: "/login" } | { kind: "forbidden"; status: 403; message: string } - | { kind: "unavailable"; status: 503; message: string }; + | { kind: "unavailable"; status: 503; message: string; location: "/admin-unavailable" }; export function resolveAdminPageAccessFailure( status: AdminPageAuthorizationStatus, @@ -14,5 +14,10 @@ export function resolveAdminPageAccessFailure( if (status === 403) { return { kind: "forbidden", status, message: "无权访问后台" }; } - return { kind: "unavailable", status, message: "后台服务暂时不可用" }; + return { + kind: "unavailable", + status, + message: "后台服务暂时不可用", + location: "/admin-unavailable", + }; } diff --git a/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql b/frontend/supabase/migrations/20260807010000_recover_initial_admin_owner.sql similarity index 89% rename from frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql rename to frontend/supabase/migrations/20260807010000_recover_initial_admin_owner.sql index 862154c1..7c757331 100644 --- a/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql +++ b/frontend/supabase/migrations/20260807010000_recover_initial_admin_owner.sql @@ -37,7 +37,9 @@ begin into v_candidate_count, v_candidate_id from identity.users u join auth.users a on a.id = u.id - where (not u.banned or (u.ban_expires is not null and u.ban_expires <= clock_timestamp())) + left join public.admin_users existing_admin on existing_admin.user_id = u.id + where (existing_admin.user_id is null or existing_admin.revoked_at is null) + and (not u.banned or (u.ban_expires is not null and u.ban_expires <= clock_timestamp())) and exists ( select 1 from unnest(string_to_array(u.role, ',')) as role_part(value) @@ -62,10 +64,7 @@ begin insert into public.admin_users (user_id, created_by) values (v_candidate_id, v_candidate_id) - on conflict on constraint admin_users_pkey do update set - revoked_at = null, - revoked_by = null, - updated_at = now(); + on conflict on constraint admin_users_pkey do nothing; insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) values (v_candidate_id, v_owner_role_id, v_candidate_id) diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index daaefd9c..ba0bdebe 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { GET as getAdminUnavailable } from "../src/app/admin-unavailable/route.ts"; import { resolveAdminPageAccessFailure } from "../src/lib/admin/page-access.ts"; const migration = readFileSync( @@ -13,7 +14,7 @@ const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", impor const authFactory = readFileSync(new URL("../src/modules/identity/auth-factory.ts", import.meta.url), "utf8"); const adminHttp = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8"); const rbacMigration = readFileSync(new URL("../supabase/migrations/20260806010000_admin_rbac.sql", import.meta.url), "utf8"); -const ownerRecoveryMigration = readFileSync(new URL("../db/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), "utf8"); +const ownerRecoveryMigration = readFileSync(new URL("../supabase/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), "utf8"); const bootstrapRoles = readFileSync(new URL("../../deploy/postgres/001-bootstrap-roles.sh", import.meta.url), "utf8"); const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensure-business-compatibility-roles.sql", import.meta.url), "utf8"); const administratorsRoute = readFileSync(new URL("../src/app/api/admin/administrators/route.ts", import.meta.url), "utf8"); @@ -100,9 +101,11 @@ test("admin authorization denial cannot enter the staging Caddy root redirect lo kind: "unavailable", status: 503, message: "后台服务暂时不可用", + location: "/admin-unavailable", }); assert.match(adminLayout, /failure\.kind === "login"[\s\S]*redirect\(failure\.location\)/); assert.match(adminLayout, /failure\.kind === "forbidden"[\s\S]*forbidden\(\)/); + assert.match(adminLayout, /failure\.kind === "unavailable"[\s\S]*redirect\(failure\.location\)/); assert.match(adminLayout, /throw error/); assert.match(adminRootRoute, /status: failure\.status/); assert.match(adminRootRoute, /"cache-control": "no-store"/); @@ -113,6 +116,16 @@ test("admin authorization denial cannot enter the staging Caddy root redirect lo assert.match(forbiddenPage, /没有后台访问权限/); }); +test("admin unavailable route terminates layout redirects with a no-store 503", async () => { + const response = getAdminUnavailable(); + + assert.equal(response.status, 503); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(response.headers.get("location"), null); + assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8"); + assert.equal(await response.text(), "后台服务暂时不可用"); +}); + test("initial Owner recovery is single-candidate, fail-closed, and independent of ADMIN_EMAILS", () => { assert.match(ownerRecoveryMigration, /v_active_owner_count > 0[\s\S]*return/); assert.match( @@ -125,6 +138,11 @@ test("initial Owner recovery is single-candidate, fail-closed, and independent o /not exists \(select 1 from identity\.users\)[\s\S]*not exists \(select 1 from auth\.users\)/, ); assert.match(ownerRecoveryMigration, /join auth\.users a on a\.id = u\.id/); + assert.match(ownerRecoveryMigration, /left join public\.admin_users existing_admin/); + assert.match( + ownerRecoveryMigration, + /existing_admin\.user_id is null or existing_admin\.revoked_at is null/, + ); assert.match( ownerRecoveryMigration, /unnest\(string_to_array\(u\.role, ','\)\)[\s\S]*btrim\(role_part\.value\) = 'admin'/, @@ -132,6 +150,14 @@ test("initial Owner recovery is single-candidate, fail-closed, and independent o assert.match(ownerRecoveryMigration, /v_candidate_count <> 1/); assert.match(ownerRecoveryMigration, /admin_owner_recovery_requires_exactly_one_active_identity_admin/); assert.match(ownerRecoveryMigration, /insert into public\.admin_users/); + assert.match( + ownerRecoveryMigration, + /on conflict on constraint admin_users_pkey do nothing/, + ); + assert.doesNotMatch( + ownerRecoveryMigration, + /on conflict on constraint admin_users_pkey do update[\s\S]*revoked_at\s*=\s*null/, + ); assert.match(ownerRecoveryMigration, /insert into public\.admin_user_roles/); assert.doesNotMatch(ownerRecoveryMigration, /ADMIN_EMAILS|email\s*=|ilike|lower\(.*email/); }); diff --git a/frontend/tests/database-admin-owner-recovery.test.ts b/frontend/tests/database-admin-owner-recovery.test.ts index f29e1020..aa285aed 100644 --- a/frontend/tests/database-admin-owner-recovery.test.ts +++ b/frontend/tests/database-admin-owner-recovery.test.ts @@ -8,14 +8,15 @@ import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); const recoveryMigration = readFileSync( - new URL("../db/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), + new URL("../supabase/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), "utf8", ); const ids = { first: "30000000-0000-4000-8000-000000000001", second: "30000000-0000-4000-8000-000000000002", - blocked: "30000000-0000-4000-8000-000000000003", - unsynced: "30000000-0000-4000-8000-000000000004", + third: "30000000-0000-4000-8000-000000000003", + blocked: "30000000-0000-4000-8000-000000000004", + unsynced: "30000000-0000-4000-8000-000000000005", }; const ownerCountSql = ` @@ -104,6 +105,44 @@ test("Owner recovery grants only one currently loginable synced identity admin", "an existing active Owner makes recovery a no-op", ); + schemaSql(` + alter table public.admin_users disable trigger admin_users_require_active_owner; + update public.admin_users + set revoked_at = timestamptz '2026-08-07 01:23:45+00', + revoked_by = '${ids.first}' + where user_id = '${ids.first}'; + alter table public.admin_users enable trigger admin_users_require_active_owner; + `); + + schemaSql(recoveryMigration); + assert.equal( + fixture.psql(` + select string_agg(aur.admin_user_id::text, ',' order by aur.admin_user_id) + from public.admin_users au + join public.admin_user_roles aur on aur.admin_user_id = au.user_id + join public.admin_roles ar on ar.id = aur.role_id + where au.revoked_at is null and ar.code = 'owner' + `), + ids.second, + "a revoked historical admin is excluded instead of being restored", + ); + assert.equal( + fixture.psql(` + select count(*) + from public.admin_users + where user_id = '${ids.first}' + and revoked_at = timestamptz '2026-08-07 01:23:45+00' + and revoked_by = '${ids.first}' + `), + "1", + "recovery preserves the historical revocation fields", + ); + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id, name, email, email_verified, email_verified_at, role) + values ('${ids.third}', 'Third Admin', 'third-admin@example.com', true, now(), 'admin') + `); + schemaSql(` alter table public.admin_user_roles disable trigger admin_user_roles_require_active_owner; delete from public.admin_user_roles @@ -117,11 +156,12 @@ test("Owner recovery grants only one currently loginable synced identity admin", assert.equal(fixture.psql(ownerCountSql), "0"); fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` - update identity.users set role = 'user' where id in ('${ids.first}', '${ids.second}') + update identity.users set role = 'user' where id in ('${ids.second}', '${ids.third}') `); assert.throws( () => schemaSql(recoveryMigration), /admin_owner_recovery_requires_exactly_one_active_identity_admin: found 0/, + "a revoked identity admin remains ineligible even when it is the only admin role", ); } finally { fixture.stop();