From 0d7e5a26d587996a687fd3202a15691d1e7049b9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 7 Aug 2026 11:06:13 +0800 Subject: [PATCH] fix(admin): break denied-session redirect loop Preserve 403/503 responses instead of redirecting them through the Caddy root, and recover Owner only for the sole loginable synced identity admin. --- docs/BUG_HISTORY.md | 16 +++ ...0807010000_recover_initial_admin_owner.sql | 88 ++++++++++++ frontend/next.config.ts | 1 + frontend/src/app/admin/layout.tsx | 7 +- frontend/src/app/admin/route.ts | 17 ++- frontend/src/app/forbidden.tsx | 23 ++++ frontend/src/lib/admin/page-access.ts | 18 +++ frontend/tests/admin-contracts.test.ts | 56 +++++++- .../database-admin-owner-recovery.test.ts | 129 ++++++++++++++++++ 9 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql create mode 100644 frontend/src/app/forbidden.tsx create mode 100644 frontend/src/lib/admin/page-access.ts create mode 100644 frontend/tests/database-admin-owner-recovery.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cbf194cf..48d929df 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2384,3 +2384,19 @@ - 相关记录:BUG-134、BUG-137、ERR-097、ERR-100、ERR-101 - 复发自:无 - 修复版本:`6c1dcbe857006ec6ae7463b57b2b7d5947da4851` + +## BUG-139 | staging 后台缺失初始 Owner 且拒绝重定向与 Caddy 形成无限循环 + +- 状态:resolved(local candidate,pending review/deployment) +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:`admin.staging.jyotisha.chat` 后台入口、self-hosted admin RBAC 初始 Owner 恢复;production 未受影响。 +- 用户现象:用户完成后台域名登录后访问 `/`,浏览器报 `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”兜底。 +- 相关记录:BUG-123、BUG-134、BUG-138 +- 复发自:BUG-123 +- 修复版本:本次 staging admin redirect/RBAC recovery 候选提交 diff --git a/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql b/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql new file mode 100644 index 00000000..862154c1 --- /dev/null +++ b/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql @@ -0,0 +1,88 @@ +begin; + +do $$ +declare + v_active_owner_count integer; + v_candidate_count integer; + v_candidate_id uuid; + v_owner_role_id uuid; +begin + -- Serialize recovery with the last-Owner protection used by the RBAC functions. + perform pg_catalog.pg_advisory_xact_lock(1096040772, 1); + + select count(*) + into v_active_owner_count + 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'; + + -- Once an active Owner exists, role management remains the only authority. + if v_active_owner_count > 0 then + return; + end if; + + -- Keep the candidate set stable while recovery decides and writes. + lock table identity.users, auth.users in share mode; + + -- A pristine database has no account to recover yet. Any populated system + -- without an Owner must have exactly one active identity-admin candidate. + if not exists (select 1 from identity.users) + and not exists (select 1 from auth.users) then + return; + end if; + + select count(*), (array_agg(u.id order by u.id))[1] + 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())) + and exists ( + select 1 + from unnest(string_to_array(u.role, ',')) as role_part(value) + where btrim(role_part.value) = 'admin' + ); + + if v_candidate_count <> 1 then + raise exception 'admin_owner_recovery_requires_exactly_one_active_identity_admin: found %', + v_candidate_count + using errcode = '23514'; + end if; + + select id + into v_owner_role_id + from public.admin_roles + where code = 'owner'; + + if v_owner_role_id is null then + raise exception 'admin_owner_recovery_owner_role_missing' + using errcode = '23514'; + end if; + + 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(); + + insert into public.admin_user_roles (admin_user_id, role_id, assigned_by) + values (v_candidate_id, v_owner_role_id, v_candidate_id) + on conflict do nothing; + + if not exists ( + select 1 + from public.admin_users au + join public.admin_user_roles aur on aur.admin_user_id = au.user_id + where au.user_id = v_candidate_id + and au.revoked_at is null + and aur.role_id = v_owner_role_id + ) then + raise exception 'admin_owner_recovery_failed' + using errcode = '23514'; + end if; +end; +$$; + +commit; diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 353c1746..7b8ac910 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -5,6 +5,7 @@ const repositoryRoot = path.join(process.cwd(), ".."); const nextConfig: NextConfig = { devIndicators: false, + experimental: { authInterrupts: true }, turbopack: { root: repositoryRoot }, outputFileTracingRoot: repositoryRoot, outputFileTracingIncludes: { diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index c7e13d4b..7a990dcb 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,10 +1,11 @@ import "@refinedev/antd/dist/reset.css"; import "antd/dist/reset.css"; import type { ReactNode } from "react"; -import { redirect } from "next/navigation"; +import { forbidden, redirect } from "next/navigation"; import { AdminApp } from "@/components/admin/admin-app"; import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; +import { resolveAdminPageAccessFailure } from "@/lib/admin/page-access"; export const dynamic = "force-dynamic"; @@ -13,7 +14,9 @@ export default async function AdminLayout({ children }: { children: ReactNode }) await requireAdminSession("read"); } catch (error) { if (error instanceof AdminAuthorizationError) { - redirect(error.status === 401 ? "/login" : "/"); + const failure = resolveAdminPageAccessFailure(error.status); + if (failure.kind === "login") redirect(failure.location); + if (failure.kind === "forbidden") forbidden(); } throw error; } diff --git a/frontend/src/app/admin/route.ts b/frontend/src/app/admin/route.ts index cc04f81d..be8c9734 100644 --- a/frontend/src/app/admin/route.ts +++ b/frontend/src/app/admin/route.ts @@ -1,4 +1,5 @@ import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; +import { resolveAdminPageAccessFailure } from "@/lib/admin/page-access"; export async function GET() { try { @@ -9,9 +10,19 @@ export async function GET() { }); } catch (error) { if (error instanceof AdminAuthorizationError) { - return new Response(null, { - status: 307, - headers: { location: error.status === 401 ? "/login" : "/" }, + const failure = resolveAdminPageAccessFailure(error.status); + if (failure.kind === "login") { + return new Response(null, { + status: 307, + headers: { location: failure.location }, + }); + } + return new Response(failure.message, { + status: failure.status, + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }, }); } throw error; diff --git a/frontend/src/app/forbidden.tsx b/frontend/src/app/forbidden.tsx new file mode 100644 index 00000000..12ef4dc7 --- /dev/null +++ b/frontend/src/app/forbidden.tsx @@ -0,0 +1,23 @@ +export default function Forbidden() { + return ( +
+
+

403

+

无权访问后台

+

+ 当前账号已登录,但没有后台访问权限。请联系管理员核对角色授权。 +

+
+
+ ); +} diff --git a/frontend/src/lib/admin/page-access.ts b/frontend/src/lib/admin/page-access.ts new file mode 100644 index 00000000..216ec938 --- /dev/null +++ b/frontend/src/lib/admin/page-access.ts @@ -0,0 +1,18 @@ +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 }; + +export function resolveAdminPageAccessFailure( + status: AdminPageAuthorizationStatus, +): AdminPageAccessFailure { + if (status === 401) { + return { kind: "login", location: "/login" }; + } + if (status === 403) { + return { kind: "forbidden", status, message: "无权访问后台" }; + } + return { kind: "unavailable", status, message: "后台服务暂时不可用" }; +} diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 061781db..daaefd9c 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { resolveAdminPageAccessFailure } from "../src/lib/admin/page-access.ts"; + const migration = readFileSync( new URL("../supabase/migrations/20260805010000_reconcile_admin_redemption_audit.sql", import.meta.url), "utf8", @@ -11,6 +13,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 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"); @@ -26,6 +29,9 @@ const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.m const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8"); const adminApp = readFileSync(new URL("../src/components/admin/admin-app.tsx", import.meta.url), "utf8"); const adminRootRoute = readFileSync(new URL("../src/app/admin/route.ts", import.meta.url), "utf8"); +const forbiddenPage = readFileSync(new URL("../src/app/forbidden.tsx", import.meta.url), "utf8"); +const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8"); +const stagingCaddy = readFileSync(new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8"); const readonlyRoutes = ["customers", "credit-transactions", "consultations", "audit-logs"].map((resource) => readFileSync(new URL(`../src/app/api/admin/${resource}/route.ts`, import.meta.url), "utf8"), ); @@ -77,13 +83,59 @@ test("admin sider replaces logout with a collapsed-aware return-to-chat link", ( test("admin pages and root route are server-gated before rendering or redirecting", () => { assert.match(adminLayout, /await requireAdminSession\("read"\)/); - assert.match(adminLayout, /error\.status === 401 \? "\/login" : "\/"/); assert.match(adminLayout, /redirect\(/); assert.match(adminRootRoute, /await requireAdminSession\("read"\)/); - assert.match(adminRootRoute, /error\.status === 401 \? "\/login" : "\/"/); assert.match(adminRootRoute, /headers: \{ location: "\/admin\/codes" \}/); }); +test("admin authorization denial cannot enter the staging Caddy root redirect loop", () => { + assert.match(stagingCaddy, /@root path \/\n\s+redir @root \/admin 308/); + assert.deepEqual(resolveAdminPageAccessFailure(401), { kind: "login", location: "/login" }); + assert.deepEqual(resolveAdminPageAccessFailure(403), { + kind: "forbidden", + status: 403, + message: "无权访问后台", + }); + assert.deepEqual(resolveAdminPageAccessFailure(503), { + kind: "unavailable", + status: 503, + message: "后台服务暂时不可用", + }); + assert.match(adminLayout, /failure\.kind === "login"[\s\S]*redirect\(failure\.location\)/); + assert.match(adminLayout, /failure\.kind === "forbidden"[\s\S]*forbidden\(\)/); + assert.match(adminLayout, /throw error/); + assert.match(adminRootRoute, /status: failure\.status/); + assert.match(adminRootRoute, /"cache-control": "no-store"/); + assert.doesNotMatch(adminLayout, /redirect\([^)]*"\/"/); + assert.doesNotMatch(adminRootRoute, /location:[^\n]*"\/"/); + assert.match(nextConfig, /authInterrupts: true/); + assert.match(forbiddenPage, /403/); + assert.match(forbiddenPage, /没有后台访问权限/); +}); + +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( + ownerRecoveryMigration, + /not u\.banned or \(u\.ban_expires is not null and u\.ban_expires <= clock_timestamp\(\)\)/, + ); + assert.match(ownerRecoveryMigration, /lock table identity\.users, auth\.users in share mode/); + assert.match( + ownerRecoveryMigration, + /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, + /unnest\(string_to_array\(u\.role, ','\)\)[\s\S]*btrim\(role_part\.value\) = 'admin'/, + ); + 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, /insert into public\.admin_user_roles/); + assert.doesNotMatch(ownerRecoveryMigration, /ADMIN_EMAILS|email\s*=|ilike|lower\(.*email/); +}); + test("readonly resources cannot be mutated through Refine access control", () => { for (const resource of ["customers", "credit-transactions", "consultations", "audit-logs"]) { assert.match(providers, new RegExp(resource.includes("-") ? `"${resource}"` : `${resource}:`)); diff --git a/frontend/tests/database-admin-owner-recovery.test.ts b/frontend/tests/database-admin-owner-recovery.test.ts new file mode 100644 index 00000000..f29e1020 --- /dev/null +++ b/frontend/tests/database-admin-owner-recovery.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +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), + "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", +}; + +const ownerCountSql = ` + select count(*) + 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' +`; + +test("Owner recovery grants only one currently loginable synced identity admin", () => { + const fixture = startPostgresFixture(); + const schemaSql = (sql: string) => fixture.psqlAs( + "schema_owner", + "schema-owner-test-password", + sql, + ); + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ), + }, + }); + assert.equal(migration.status, 0, migration.stderr); + assert.match(migration.stdout, /applied 20260807010000_recover_initial_admin_owner\.sql/); + assert.equal(fixture.psql(ownerCountSql), "0", "a truly empty database stays empty"); + + schemaSql(` + alter table identity.users disable trigger identity_user_business_auth_sync; + insert into identity.users (id, name, email, email_verified, email_verified_at, role) + values ('${ids.unsynced}', 'Unsynced Admin', 'unsynced-admin@example.com', true, now(), 'admin'); + alter table identity.users enable trigger identity_user_business_auth_sync; + `); + assert.equal( + fixture.psql(`select count(*) from auth.users where id = '${ids.unsynced}'`), + "0", + ); + assert.throws( + () => schemaSql(recoveryMigration), + /admin_owner_recovery_requires_exactly_one_active_identity_admin: found 0/, + "an unsynced identity account is not a truly empty database", + ); + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users + (id, name, email, email_verified, email_verified_at, role, banned, ban_expires) + values + ('${ids.first}', 'First Admin', 'first-admin@example.com', true, now(), ' user , admin ', true, now() - interval '1 hour'), + ('${ids.blocked}', 'Blocked Admin', 'blocked-admin@example.com', true, now(), 'admin', true, null) + `); + + schemaSql(recoveryMigration); + assert.equal( + fixture.psql(` + select count(*) + 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.user_id = '${ids.first}' and au.revoked_at is null and ar.code = 'owner' + `), + "1", + ); + assert.equal(fixture.psql(ownerCountSql), "1"); + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id, name, email, email_verified, email_verified_at, role) + values ('${ids.second}', 'Second Admin', 'second-admin@example.com', true, now(), 'admin') + `); + + 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.first, + "an existing active Owner makes recovery a no-op", + ); + + schemaSql(` + alter table public.admin_user_roles disable trigger admin_user_roles_require_active_owner; + delete from public.admin_user_roles + where role_id = (select id from public.admin_roles where code = 'owner'); + alter table public.admin_user_roles enable trigger admin_user_roles_require_active_owner; + `); + assert.throws( + () => schemaSql(recoveryMigration), + /admin_owner_recovery_requires_exactly_one_active_identity_admin: found 2/, + ); + 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}') + `); + assert.throws( + () => schemaSql(recoveryMigration), + /admin_owner_recovery_requires_exactly_one_active_identity_admin: found 0/, + ); + } finally { + fixture.stop(); + } +});