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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user