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 = {