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 312 additions and 108 deletions
@@ -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);
@@ -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 },
);
}
+98
View File
@@ -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<IdentityConfig> = {
readAuthProvider: () => string | undefined;
readRequestHeaders: () => Headers | Promise<Headers>;
readIdentityConfig: () => IdentityConfig;
resolveIdentitySurface: (
host: string | null,
config: IdentityConfig,
) => "user" | "admin" | null;
requireIdentitySession: (
requestHeaders: Headers,
) => Promise<IdentityServerSession>;
loadAdminSession: (
user: IdentityUser,
identitySession: AdminSession["identitySession"],
) => Promise<AdminSession>;
};
export async function authorizeAdminRequest<IdentityConfig>(
permission: AdminPermission,
requestHeaders: Headers | undefined,
dependencies: AdminAuthorizationDependencies<IdentityConfig>,
): Promise<AdminSession> {
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);
}
}
+19 -66
View File
@@ -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<AdminSession> {
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(
+3 -18
View File
@@ -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);
+129 -17
View File
@@ -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<TestConfig>> = {},
): AdminAuthorizationDependencies<TestConfig> {
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<AdminAuthorizationDependencies<TestConfig>>,
): Promise<AdminAuthorizationError> {
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.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<Partial<AdminAuthorizationDependencies<TestConfig>>> = [
{
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.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(response.status, 503);
assert.deepEqual(await response.json(), { error: "后台服务暂时不可用" });
});
+21 -3
View File
@@ -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(
@@ -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 = {