From 66a6c96a5caeec95afb8dbf586426eab450e751e Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 7 Aug 2026 12:08:58 +0800 Subject: [PATCH] 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. --- docs/BUG_HISTORY.md | 6 +- ...0807010000_recover_initial_admin_owner.sql | 13 ++ .../src/lib/admin/admin-error-response.ts | 25 +++ frontend/src/lib/admin/auth-boundary.ts | 98 ++++++++++++ frontend/src/lib/admin/auth.ts | 85 +++------- frontend/src/lib/admin/http.ts | 21 +-- frontend/tests/admin-auth.test.ts | 150 +++++++++++++++--- frontend/tests/admin-contracts.test.ts | 24 ++- .../database-admin-owner-recovery.test.ts | 2 +- 9 files changed, 314 insertions(+), 110 deletions(-) rename frontend/{supabase => db}/migrations/20260807010000_recover_initial_admin_owner.sql (80%) create mode 100644 frontend/src/lib/admin/admin-error-response.ts create mode 100644 frontend/src/lib/admin/auth-boundary.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b4455892..6ec1d25d 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 以 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,其余未知基础设施异常统一转换为 `后台服务暂时不可用` 503,API `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 候选提交 diff --git a/frontend/supabase/migrations/20260807010000_recover_initial_admin_owner.sql b/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql similarity index 80% rename from frontend/supabase/migrations/20260807010000_recover_initial_admin_owner.sql rename to frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql index 7c757331..404d8257 100644 --- a/frontend/supabase/migrations/20260807010000_recover_initial_admin_owner.sql +++ b/frontend/db/migrations/20260807010000_recover_initial_admin_owner.sql @@ -7,6 +7,19 @@ declare v_candidate_id uuid; v_owner_role_id uuid; begin + -- This migration also belongs to the identity-only db migration ledger. When + -- the Supabase RBAC schema is not part of that migration flow, record a safe + -- no-op without referencing any missing relation or trigger function. + if to_regclass('public.admin_users') is null + or to_regclass('public.admin_roles') is null + or to_regclass('public.admin_user_roles') is null + or to_regclass('identity.users') is null + or to_regclass('auth.users') is null + or to_regprocedure('public.admin_permission_keys(uuid)') is null + or to_regprocedure('public.assert_active_admin_owner_exists()') is null then + return; + end if; + -- Serialize recovery with the last-Owner protection used by the RBAC functions. perform pg_catalog.pg_advisory_xact_lock(1096040772, 1); diff --git a/frontend/src/lib/admin/admin-error-response.ts b/frontend/src/lib/admin/admin-error-response.ts new file mode 100644 index 00000000..d73a7d3c --- /dev/null +++ b/frontend/src/lib/admin/admin-error-response.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; + +import { AdminAuthorizationError } from "./auth-boundary"; + +function isPostgresError(error: unknown): error is { code: string } { + return typeof error === "object" && error !== null && "code" in error + && typeof (error as { code?: unknown }).code === "string"; +} + +export function adminErrorResponse(error: unknown) { + if (error instanceof AdminAuthorizationError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + if (isPostgresError(error)) { + if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 }); + if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 }); + if (error.code === "22023" || error.code === "23514" || error.code === "23505") { + return NextResponse.json({ error: "提交内容不符合业务约束" }, { status: 400 }); + } + } + return NextResponse.json( + { error: "后台服务暂时不可用" }, + { status: 500 }, + ); +} diff --git a/frontend/src/lib/admin/auth-boundary.ts b/frontend/src/lib/admin/auth-boundary.ts new file mode 100644 index 00000000..6f9941c7 --- /dev/null +++ b/frontend/src/lib/admin/auth-boundary.ts @@ -0,0 +1,98 @@ +import type { IdentityUser } from "@/modules/identity/contracts"; +import { + IdentityAuthorizationError, + type IdentityServerSession, +} from "@/modules/identity/session"; +import { + authorizeAdminAccess, + type AdminPermission, + type AdminRole, +} from "./auth-policy"; + +export type AdminSession = { + user: IdentityUser; + roles: AdminRole[]; + permissions: AdminPermission[]; + requiresMfa: boolean; + identitySession: { + id: string; + token: string; + expiresAt: Date; + }; +}; + +export class AdminAuthorizationError extends Error { + constructor( + message: string, + readonly status: 401 | 403 | 503, + ) { + super(message); + this.name = "AdminAuthorizationError"; + } +} + +export type AdminAuthorizationDependencies = { + readAuthProvider: () => string | undefined; + readRequestHeaders: () => Headers | Promise; + readIdentityConfig: () => IdentityConfig; + resolveIdentitySurface: ( + host: string | null, + config: IdentityConfig, + ) => "user" | "admin" | null; + requireIdentitySession: ( + requestHeaders: Headers, + ) => Promise; + loadAdminSession: ( + user: IdentityUser, + identitySession: AdminSession["identitySession"], + ) => Promise; +}; + +export async function authorizeAdminRequest( + permission: AdminPermission, + requestHeaders: Headers | undefined, + dependencies: AdminAuthorizationDependencies, +): Promise { + try { + if (dependencies.readAuthProvider()?.trim() !== "self-hosted") { + throw new AdminAuthorizationError("后台身份服务未启用", 403); + } + + const adminHeaders = requestHeaders + ?? new Headers(await dependencies.readRequestHeaders()); + const identityConfig = dependencies.readIdentityConfig(); + if ( + dependencies.resolveIdentitySurface( + adminHeaders.get("host"), + identityConfig, + ) !== "admin" + ) { + throw new AdminAuthorizationError("无权访问后台", 403); + } + + const identitySession = await dependencies.requireIdentitySession(adminHeaders); + const session = await dependencies.loadAdminSession(identitySession.user, { + id: identitySession.sessionId, + token: identitySession.sessionToken, + expiresAt: identitySession.expiresAt, + }); + const authorization = authorizeAdminAccess( + identitySession.user, + session.permissions, + permission, + ); + if (!authorization.allowed) { + throw new AdminAuthorizationError("无权执行此操作", authorization.status); + } + return session; + } catch (error) { + if (error instanceof AdminAuthorizationError) throw error; + if (error instanceof IdentityAuthorizationError) { + throw new AdminAuthorizationError( + error.status === 401 ? "请先登录" : "无权访问后台", + error.status, + ); + } + throw new AdminAuthorizationError("后台服务暂时不可用", 503); + } +} diff --git a/frontend/src/lib/admin/auth.ts b/frontend/src/lib/admin/auth.ts index 92efc2c9..a0363ac2 100644 --- a/frontend/src/lib/admin/auth.ts +++ b/frontend/src/lib/admin/auth.ts @@ -5,13 +5,13 @@ import { headers } from "next/headers"; import { getIdentityAuthServices } from "@/modules/identity/auth"; import { readSelfHostedIdentityConfig } from "@/modules/identity/config"; import { resolveIdentitySurface } from "@/modules/identity/host"; -import { - IdentityAuthorizationError, - requireIdentityServerSession, -} from "@/modules/identity/session"; +import { requireIdentityServerSession } from "@/modules/identity/session"; import type { IdentityUser } from "@/modules/identity/contracts"; import { - authorizeAdminAccess, + authorizeAdminRequest, + type AdminSession, +} from "./auth-boundary"; +import { type AdminPermission, type AdminRole, } from "./auth-policy"; @@ -25,27 +25,8 @@ type PermissionRow = { requires_mfa: boolean; }; -export type AdminSession = { - user: IdentityUser; - roles: AdminRole[]; - permissions: AdminPermission[]; - requiresMfa: boolean; - identitySession: { - id: string; - token: string; - expiresAt: Date; - }; -}; - -export class AdminAuthorizationError extends Error { - constructor( - message: string, - readonly status: 401 | 403 | 503, - ) { - super(message); - this.name = "AdminAuthorizationError"; - } -} +export { AdminAuthorizationError } from "./auth-boundary"; +export type { AdminSession } from "./auth-boundary"; async function loadAdminSession( user: IdentityUser, @@ -70,46 +51,18 @@ export async function requirePermission( permission: AdminPermission = "admin.access", requestHeaders?: Headers, ): Promise { - if (process.env.AUTH_PROVIDER?.trim() !== "self-hosted") { - throw new AdminAuthorizationError("后台身份服务未启用", 403); - } - - const adminHeaders = requestHeaders ?? new Headers(await headers()); - const identityConfig = readSelfHostedIdentityConfig(process.env); - if (resolveIdentitySurface(adminHeaders.get("host"), identityConfig) !== "admin") { - throw new AdminAuthorizationError("无权访问后台", 403); - } - - try { - const identitySession = await requireIdentityServerSession( - getIdentityAuthServices().user.api, - adminHeaders, - ); - const session = await loadAdminSession(identitySession.user, { - id: identitySession.sessionId, - token: identitySession.sessionToken, - expiresAt: identitySession.expiresAt, - }); - const user = identitySession.user; - const authorization = authorizeAdminAccess( - user, - session.permissions, - permission, - ); - if (!authorization.allowed) { - throw new AdminAuthorizationError("无权执行此操作", authorization.status); - } - return session; - } catch (error) { - if (error instanceof AdminAuthorizationError) throw error; - if (error instanceof IdentityAuthorizationError) { - throw new AdminAuthorizationError( - error.status === 401 ? "请先登录" : "无权访问后台", - error.status, - ); - } - throw error; - } + return authorizeAdminRequest(permission, requestHeaders, { + readAuthProvider: () => process.env.AUTH_PROVIDER, + readRequestHeaders: async () => new Headers(await headers()), + readIdentityConfig: () => readSelfHostedIdentityConfig(process.env), + resolveIdentitySurface, + requireIdentitySession: async (adminHeaders) => + requireIdentityServerSession( + getIdentityAuthServices().user.api, + adminHeaders, + ), + loadAdminSession, + }); } export function requireAdminSession( diff --git a/frontend/src/lib/admin/http.ts b/frontend/src/lib/admin/http.ts index 7939b997..2c944d3d 100644 --- a/frontend/src/lib/admin/http.ts +++ b/frontend/src/lib/admin/http.ts @@ -16,7 +16,9 @@ import { verifyHighRiskAdminProof, type AdminMfaStatus, } from "./auth-policy"; -import { isPostgresError } from "./database"; +import { adminErrorResponse } from "./admin-error-response"; + +export { adminErrorResponse } from "./admin-error-response"; export const listQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -40,23 +42,6 @@ export function requestId(request: Request): string { return supplied && supplied.length <= 200 ? supplied : crypto.randomUUID(); } -export function adminErrorResponse(error: unknown) { - if (error instanceof AdminAuthorizationError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - if (isPostgresError(error)) { - if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 }); - if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 }); - if (error.code === "22023" || error.code === "23514" || error.code === "23505") { - return NextResponse.json({ error: "提交内容不符合业务约束" }, { status: 400 }); - } - } - return NextResponse.json( - { error: "后台服务暂时不可用" }, - { status: 500 }, - ); -} - export async function requireAdminMutation(request: Request, permission: AdminPermission) { if (!isSameOriginAdminMutation(request.headers.get("origin"), request.url)) { throw new AdminAuthorizationError("请求来源不可信", 403); diff --git a/frontend/tests/admin-auth.test.ts b/frontend/tests/admin-auth.test.ts index d4b5d0e6..76ea6df6 100644 --- a/frontend/tests/admin-auth.test.ts +++ b/frontend/tests/admin-auth.test.ts @@ -1,14 +1,19 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import test from "node:test"; +import { adminErrorResponse } from "../src/lib/admin/admin-error-response.ts"; +import { + AdminAuthorizationError, + authorizeAdminRequest, + type AdminAuthorizationDependencies, + type AdminSession, +} from "../src/lib/admin/auth-boundary.ts"; import { authorizeAdminAccess } from "../src/lib/admin/auth-policy.ts"; import type { IdentityUser } from "../src/modules/identity/contracts.ts"; - -const adminAuthSource = readFileSync( - new URL("../src/lib/admin/auth.ts", import.meta.url), - "utf8", -); +import { + IdentityAuthorizationError, + type IdentityServerSession, +} from "../src/modules/identity/session.ts"; function user(): IdentityUser { return { @@ -22,6 +27,59 @@ function user(): IdentityUser { }; } +function identitySession(): IdentityServerSession { + return { + user: user(), + sessionId: "session-id", + sessionToken: "session-token", + expiresAt: new Date("2026-08-08T00:00:00.000Z"), + }; +} + +function adminSession(): AdminSession { + const identity = identitySession(); + return { + user: identity.user, + roles: ["owner"], + permissions: ["admin.access"], + requiresMfa: false, + identitySession: { + id: identity.sessionId, + token: identity.sessionToken, + expiresAt: identity.expiresAt, + }, + }; +} + +type TestConfig = { adminHost: string }; + +function dependencies( + overrides: Partial> = {}, +): AdminAuthorizationDependencies { + return { + readAuthProvider: () => "self-hosted", + readRequestHeaders: () => new Headers({ host: "admin.example.com" }), + readIdentityConfig: () => ({ adminHost: "admin.example.com" }), + resolveIdentitySurface: (host, config) => + host === config.adminHost ? "admin" : null, + requireIdentitySession: async () => identitySession(), + loadAdminSession: async () => adminSession(), + ...overrides, + }; +} + +async function rejectedAuthorization( + overrides: Partial>, +): Promise { + try { + await authorizeAdminRequest("admin.access", undefined, dependencies(overrides)); + } catch (error) { + assert.ok(error instanceof AdminAuthorizationError); + return error; + } + assert.fail("authorization unexpectedly succeeded"); +} + test("anonymous admin access is 401", () => { assert.deepEqual(authorizeAdminAccess(null, [], "admin.access"), { allowed: false, @@ -49,18 +107,72 @@ test("database permission keys authorize only the requested operation", () => { }); }); +test("admin authorization checks the configured admin Host before session lookup", async () => { + let sessionRead = false; + const error = await rejectedAuthorization({ + readRequestHeaders: () => new Headers({ host: "user.example.com" }), + requireIdentitySession: async () => { + sessionRead = true; + return identitySession(); + }, + }); -test("admin session guard requires the configured admin Host before session lookup", () => { - const guard = adminAuthSource.slice( - adminAuthSource.indexOf("export async function requirePermission"), - adminAuthSource.indexOf("export function requireAdminSession"), - ); - - assert.match(guard, /readSelfHostedIdentityConfig\(process\.env\)/); - assert.match(guard, /resolveIdentitySurface\(adminHeaders\.get\("host"\), identityConfig\) !== "admin"/); - assert.ok( - guard.indexOf("resolveIdentitySurface") < guard.indexOf("requireIdentityServerSession"), - "Host must be rejected before Better Auth session lookup", - ); - assert.doesNotMatch(guard, /x-forwarded-host|forwarded/i); + assert.equal(error.status, 403); + assert.equal(error.message, "无权访问后台"); + assert.equal(sessionRead, false); +}); + +test("known admin and identity authorization errors preserve 401/403 semantics", async () => { + const original = new AdminAuthorizationError("请求来源不可信", 403); + const preserved = await rejectedAuthorization({ + readIdentityConfig: () => { + throw original; + }, + }); + assert.equal(preserved, original); + + for (const [status, message] of [[401, "请先登录"], [403, "无权访问后台"]] as const) { + const mapped = await rejectedAuthorization({ + requireIdentitySession: async () => { + throw new IdentityAuthorizationError("provider detail", status); + }, + }); + assert.equal(mapped.status, status); + assert.equal(mapped.message, message); + } +}); + +test("configuration, identity reader, and RBAC query failures become sanitized 503", async () => { + const failures: Array>> = [ + { + readIdentityConfig: () => { + throw new Error("BETTER_AUTH_USER_SECRET leaked detail"); + }, + }, + { + requireIdentitySession: async () => { + throw new Error("better-auth or identity database leaked detail"); + }, + }, + { + loadAdminSession: async () => { + throw new Error("postgres admin_permission_keys leaked detail"); + }, + }, + ]; + + for (const failure of failures) { + const error = await rejectedAuthorization(failure); + assert.equal(error.status, 503); + assert.equal(error.message, "后台服务暂时不可用"); + } +}); + +test("adminErrorResponse preserves the sanitized authorization 503", async () => { + const response = adminErrorResponse( + new AdminAuthorizationError("后台服务暂时不可用", 503), + ); + + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: "后台服务暂时不可用" }); }); diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index ba0bdebe..be89cdb5 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -10,11 +10,12 @@ const migration = readFileSync( "utf8", ); const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8"); +const authBoundary = readFileSync(new URL("../src/lib/admin/auth-boundary.ts", import.meta.url), "utf8"); const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", import.meta.url), "utf8"); 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("../supabase/migrations/20260807010000_recover_initial_admin_owner.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"); @@ -44,7 +45,8 @@ test("admin APIs use persisted Better Auth roles with admin-only boundaries", () assert.match(authPolicy, /permissions\.includes\(required\)/); assert.match(auth, /admin_permission_keys\(\$1\)/); assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/); - assert.match(auth, /AUTH_PROVIDER\?\.trim\(\) !== "self-hosted"/); + assert.match(authBoundary, /readAuthProvider\(\)\?\.trim\(\) !== "self-hosted"/); + assert.match(authBoundary, /后台服务暂时不可用", 503/); assert.match(codesRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/); assert.match(codeRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/g); }); @@ -58,7 +60,7 @@ test("self-hosted account entry uses the database permission graph", () => { assert.match(selfHostedBranch, /queryAdminRows/); assert.match(selfHostedBranch, /admin_has_permission\(\$1, 'admin\.access'\)/); assert.doesNotMatch(selfHostedBranch, /role === "admin"|viewer|isAdminEmail|ADMIN_EMAILS/); - assert.match(auth, /authorizeAdminAccess\([\s\S]*user,[\s\S]*session\.permissions,[\s\S]*permission/); + assert.match(authBoundary, /authorizeAdminAccess\([\s\S]*identitySession\.user,[\s\S]*session\.permissions,[\s\S]*permission/); }); test("admin navigation exposes separated RBAC and billing resources", () => { @@ -126,6 +128,22 @@ test("admin unavailable route terminates layout redirects with a no-store 503", assert.equal(await response.text(), "后台服务暂时不可用"); }); +test("initial Owner recovery safely no-ops until every RBAC prerequisite exists", () => { + for (const prerequisite of [ + "public.admin_users", + "public.admin_roles", + "public.admin_user_roles", + "identity.users", + "auth.users", + ]) { + const escaped = prerequisite.replaceAll(".", "\\."); + assert.match(ownerRecoveryMigration, new RegExp(`to_regclass\\('${escaped}'\\) is null`)); + } + assert.match(ownerRecoveryMigration, /to_regprocedure\('public\.admin_permission_keys\(uuid\)'\) is null/); + assert.match(ownerRecoveryMigration, /to_regprocedure\('public\.assert_active_admin_owner_exists\(\)'\) is null/); + assert.match(ownerRecoveryMigration, /then[\s\S]*return;[\s\S]*end if;[\s\S]*pg_advisory_xact_lock/); +}); + 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( diff --git a/frontend/tests/database-admin-owner-recovery.test.ts b/frontend/tests/database-admin-owner-recovery.test.ts index aa285aed..3b9f9008 100644 --- a/frontend/tests/database-admin-owner-recovery.test.ts +++ b/frontend/tests/database-admin-owner-recovery.test.ts @@ -8,7 +8,7 @@ import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); const recoveryMigration = readFileSync( - new URL("../supabase/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), + new URL("../db/migrations/20260807010000_recover_initial_admin_owner.sql", import.meta.url), "utf8", ); const ids = {