fix(admin): preserve revoked owners and 503 status

Owner recovery lived in the identity-only migration path and could clear historical revocations on conflict. Admin layout also rethrew authorization 503s, turning service-unavailable failures into 500 responses.\n\nMove recovery behind the RBAC migration sequence, exclude revoked candidates without mutating their history, and terminate layout redirects at a no-store 503 route.
This commit is contained in:
Jesse_Chen
2026-08-07 11:33:48 +08:00
parent 0d7e5a26d5
commit 754505d40a
7 changed files with 95 additions and 15 deletions
@@ -0,0 +1,9 @@
export function GET() {
return new Response("后台服务暂时不可用", {
status: 503,
headers: {
"cache-control": "no-store",
"content-type": "text/plain; charset=utf-8",
},
});
}
+1
View File
@@ -17,6 +17,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
const failure = resolveAdminPageAccessFailure(error.status);
if (failure.kind === "login") redirect(failure.location);
if (failure.kind === "forbidden") forbidden();
if (failure.kind === "unavailable") redirect(failure.location);
}
throw error;
}
+7 -2
View File
@@ -3,7 +3,7 @@ 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 };
| { kind: "unavailable"; status: 503; message: string; location: "/admin-unavailable" };
export function resolveAdminPageAccessFailure(
status: AdminPageAuthorizationStatus,
@@ -14,5 +14,10 @@ export function resolveAdminPageAccessFailure(
if (status === 403) {
return { kind: "forbidden", status, message: "无权访问后台" };
}
return { kind: "unavailable", status, message: "后台服务暂时不可用" };
return {
kind: "unavailable",
status,
message: "后台服务暂时不可用",
location: "/admin-unavailable",
};
}
@@ -37,7 +37,9 @@ begin
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()))
left join public.admin_users existing_admin on existing_admin.user_id = u.id
where (existing_admin.user_id is null or existing_admin.revoked_at is null)
and (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)
@@ -62,10 +64,7 @@ begin
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();
on conflict on constraint admin_users_pkey do nothing;
insert into public.admin_user_roles (admin_user_id, role_id, assigned_by)
values (v_candidate_id, v_owner_role_id, v_candidate_id)
+27 -1
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { GET as getAdminUnavailable } from "../src/app/admin-unavailable/route.ts";
import { resolveAdminPageAccessFailure } from "../src/lib/admin/page-access.ts";
const migration = readFileSync(
@@ -13,7 +14,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 ownerRecoveryMigration = readFileSync(new URL("../supabase/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");
@@ -100,9 +101,11 @@ test("admin authorization denial cannot enter the staging Caddy root redirect lo
kind: "unavailable",
status: 503,
message: "后台服务暂时不可用",
location: "/admin-unavailable",
});
assert.match(adminLayout, /failure\.kind === "login"[\s\S]*redirect\(failure\.location\)/);
assert.match(adminLayout, /failure\.kind === "forbidden"[\s\S]*forbidden\(\)/);
assert.match(adminLayout, /failure\.kind === "unavailable"[\s\S]*redirect\(failure\.location\)/);
assert.match(adminLayout, /throw error/);
assert.match(adminRootRoute, /status: failure\.status/);
assert.match(adminRootRoute, /"cache-control": "no-store"/);
@@ -113,6 +116,16 @@ test("admin authorization denial cannot enter the staging Caddy root redirect lo
assert.match(forbiddenPage, /没有后台访问权限/);
});
test("admin unavailable route terminates layout redirects with a no-store 503", async () => {
const response = getAdminUnavailable();
assert.equal(response.status, 503);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(response.headers.get("location"), null);
assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8");
assert.equal(await response.text(), "后台服务暂时不可用");
});
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(
@@ -125,6 +138,11 @@ test("initial Owner recovery is single-candidate, fail-closed, and independent o
/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, /left join public\.admin_users existing_admin/);
assert.match(
ownerRecoveryMigration,
/existing_admin\.user_id is null or existing_admin\.revoked_at is null/,
);
assert.match(
ownerRecoveryMigration,
/unnest\(string_to_array\(u\.role, ','\)\)[\s\S]*btrim\(role_part\.value\) = 'admin'/,
@@ -132,6 +150,14 @@ test("initial Owner recovery is single-candidate, fail-closed, and independent o
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,
/on conflict on constraint admin_users_pkey do nothing/,
);
assert.doesNotMatch(
ownerRecoveryMigration,
/on conflict on constraint admin_users_pkey do update[\s\S]*revoked_at\s*=\s*null/,
);
assert.match(ownerRecoveryMigration, /insert into public\.admin_user_roles/);
assert.doesNotMatch(ownerRecoveryMigration, /ADMIN_EMAILS|email\s*=|ilike|lower\(.*email/);
});
@@ -8,14 +8,15 @@ 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),
new URL("../supabase/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",
third: "30000000-0000-4000-8000-000000000003",
blocked: "30000000-0000-4000-8000-000000000004",
unsynced: "30000000-0000-4000-8000-000000000005",
};
const ownerCountSql = `
@@ -104,6 +105,44 @@ test("Owner recovery grants only one currently loginable synced identity admin",
"an existing active Owner makes recovery a no-op",
);
schemaSql(`
alter table public.admin_users disable trigger admin_users_require_active_owner;
update public.admin_users
set revoked_at = timestamptz '2026-08-07 01:23:45+00',
revoked_by = '${ids.first}'
where user_id = '${ids.first}';
alter table public.admin_users enable trigger admin_users_require_active_owner;
`);
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.second,
"a revoked historical admin is excluded instead of being restored",
);
assert.equal(
fixture.psql(`
select count(*)
from public.admin_users
where user_id = '${ids.first}'
and revoked_at = timestamptz '2026-08-07 01:23:45+00'
and revoked_by = '${ids.first}'
`),
"1",
"recovery preserves the historical revocation fields",
);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (id, name, email, email_verified, email_verified_at, role)
values ('${ids.third}', 'Third Admin', 'third-admin@example.com', true, now(), 'admin')
`);
schemaSql(`
alter table public.admin_user_roles disable trigger admin_user_roles_require_active_owner;
delete from public.admin_user_roles
@@ -117,11 +156,12 @@ test("Owner recovery grants only one currently loginable synced identity admin",
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}')
update identity.users set role = 'user' where id in ('${ids.second}', '${ids.third}')
`);
assert.throws(
() => schemaSql(recoveryMigration),
/admin_owner_recovery_requires_exactly_one_active_identity_admin: found 0/,
"a revoked identity admin remains ineligible even when it is the only admin role",
);
} finally {
fixture.stop();