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:
@@ -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: "后台服务暂时不可用" });
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user