fix(admin): remove operation-level email reauthentication
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
readAdminMfaStatus,
|
||||
requireHighRiskAdminMutation,
|
||||
requireAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
@@ -68,7 +68,6 @@ export async function GET(request: Request) {
|
||||
roles: row.roles,
|
||||
})),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
highRiskWritesEnabled: mfa.highRiskWritesEnabled,
|
||||
mfa,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -78,7 +77,7 @@ export async function GET(request: Request) {
|
||||
|
||||
async function mutate(request: Request, assign: boolean) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(request, "admin.users.manage_roles");
|
||||
const session = await requireAdminMutation(request, "admin.users.manage_roles");
|
||||
const parsed = mutationSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const targetRows = parsed.data.userId
|
||||
|
||||
@@ -5,7 +5,7 @@ import { runCodeRpc } from "@/lib/admin/codes";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
requireHighRiskAdminMutation,
|
||||
requireAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function PATCH(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
const session = await requireAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
@@ -66,7 +66,7 @@ export async function DELETE(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
const session = await requireAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requireHighRiskAdminMutation,
|
||||
requireAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
import {
|
||||
@@ -123,7 +123,7 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
const session = await requireAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { isPostgresError, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { adminErrorResponse, requireAdminMutation } from "@/lib/admin/http";
|
||||
import { suggestedEpayUrls } from "@/lib/epay/config";
|
||||
import { encryptEpayKey } from "@/lib/epay/encryption";
|
||||
import { assertConfiguredEpayUrl, assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
|
||||
@@ -99,7 +99,7 @@ export async function GET() {
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(request, "billing.adjustments.write");
|
||||
const session = await requireAdminMutation(request, "billing.adjustments.write");
|
||||
const parsed = settingsSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 });
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
|
||||
export const runtime="nodejs";
|
||||
const schema=z.discriminatedUnion("action",[
|
||||
z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional(),reason:z.string().trim().min(1).max(500)}).strict(),
|
||||
@@ -10,4 +10,4 @@ const schema=z.discriminatedUnion("action",[
|
||||
]);
|
||||
type Row={id:string;flag_key:string;version:number;enabled:boolean;rollout_percentage:number;config:Record<string,unknown>;status:string;created_at:Date;published_at:Date|null;total_count:string};
|
||||
export async function GET(request:Request){try{await requirePermission("admin.access");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select f.*,count(*) over()::text total_count from public.feature_flags f where ($1::text is null or f.flag_key ilike $1) and ($2::text is null or f.status=$2) order by f.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,flagKey:r.flag_key,version:r.version,enabled:r.enabled,rolloutPercentage:r.rollout_percentage,config:r.config,status:r.status,createdAt:r.created_at.toISOString(),publishedAt:r.published_at?.toISOString()??null})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
|
||||
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=b.data.action==="publish"?await requireHighRiskAdminMutation(request,"ops.flags.write"):await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
|
||||
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { AdminAuthorizationError, requirePermission, type AdminSession } from "@
|
||||
import {
|
||||
ADMIN_MFA_PROOF_COOKIE,
|
||||
ADMIN_MFA_PROOF_TTL_MS,
|
||||
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
|
||||
HIGH_RISK_ADMIN_PROOF_COOKIE,
|
||||
issueAdminMfaProof,
|
||||
} from "@/lib/admin/auth-policy";
|
||||
import {
|
||||
@@ -114,7 +112,6 @@ function mfaProofResponse(
|
||||
required: session.requiresMfa,
|
||||
enrolled: true,
|
||||
verified: true,
|
||||
highRiskWritesEnabled: true,
|
||||
expiresIn: ADMIN_MFA_PROOF_TTL_MS / 1_000,
|
||||
},
|
||||
});
|
||||
@@ -282,23 +279,16 @@ export async function POST(request: Request) {
|
||||
required: session.requiresMfa,
|
||||
enrolled: false,
|
||||
verified: false,
|
||||
highRiskWritesEnabled: !session.requiresMfa,
|
||||
},
|
||||
});
|
||||
copyNativeCookies(nativeResponse, response);
|
||||
for (const name of [
|
||||
ADMIN_MFA_PROOF_COOKIE,
|
||||
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
|
||||
HIGH_RISK_ADMIN_PROOF_COOKIE,
|
||||
]) {
|
||||
response.cookies.set(name, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: true,
|
||||
path: "/api/admin",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
response.cookies.set(ADMIN_MFA_PROOF_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: true,
|
||||
path: "/api/admin",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
return nativeErrorResponse(error) ?? adminErrorResponse(error);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requestId,
|
||||
requireHighRiskAdminMutation,
|
||||
requireAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -108,7 +108,7 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
const session = await requireAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function POST(request: Request) {
|
||||
const body = mutationSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) return invalidQueryResponse(body.error.flatten());
|
||||
const permission = body.data.action === "publish" ? "billing.products.publish" : "billing.products.write";
|
||||
const session = await requireHighRiskAdminMutation(request, permission);
|
||||
const session = await requireAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
if (body.data.action === "publish") {
|
||||
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, body.data.reason, rid]);
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { APIError } from "better-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getIdentityEmailOtpApi } from "@/modules/identity/auth";
|
||||
import { AdminAuthorizationError } from "@/lib/admin/auth";
|
||||
import {
|
||||
adminPermissions,
|
||||
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
|
||||
HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
|
||||
HIGH_RISK_ADMIN_PROOF_COOKIE,
|
||||
HIGH_RISK_ADMIN_PROOF_TTL_MS,
|
||||
issueHighRiskAdminChallenge,
|
||||
issueHighRiskAdminProof,
|
||||
verifyHighRiskAdminChallenge,
|
||||
} from "@/lib/admin/auth-policy";
|
||||
import {
|
||||
adminProofSigningSecret,
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
requestCookie,
|
||||
requireAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const reauthSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("request"),
|
||||
permission: z.enum(adminPermissions),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("verify"),
|
||||
permission: z.enum(adminPermissions),
|
||||
otp: z.string().regex(/^\d{6}$/),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const parsed = reauthSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
|
||||
const session = await requireAdminMutation(request, parsed.data.permission);
|
||||
const auth = getIdentityEmailOtpApi();
|
||||
const proofContext = {
|
||||
userId: session.user.id,
|
||||
sessionId: session.identitySession.id,
|
||||
permission: parsed.data.permission,
|
||||
origin: new URL(request.url).origin,
|
||||
};
|
||||
const proofSecret = adminProofSigningSecret();
|
||||
if (parsed.data.action === "request") {
|
||||
await auth.sendVerificationOTP({
|
||||
body: { email: session.user.email, type: "email-verification" },
|
||||
});
|
||||
const response = NextResponse.json({ data: { sent: true, expiresIn: 300 } });
|
||||
response.cookies.set(
|
||||
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
|
||||
issueHighRiskAdminChallenge(
|
||||
proofContext,
|
||||
proofSecret,
|
||||
session.identitySession.token,
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: true,
|
||||
path: "/api/admin",
|
||||
maxAge: HIGH_RISK_ADMIN_CHALLENGE_TTL_MS / 1_000,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!verifyHighRiskAdminChallenge(
|
||||
requestCookie(request, HIGH_RISK_ADMIN_CHALLENGE_COOKIE),
|
||||
proofContext,
|
||||
proofSecret,
|
||||
session.identitySession.token,
|
||||
)) {
|
||||
throw new AdminAuthorizationError("请先请求当前权限的邮箱验证码", 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.verifyEmailOTP({
|
||||
body: { email: session.user.email, otp: parsed.data.otp },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof APIError && error.statusCode >= 400 && error.statusCode < 500) {
|
||||
return NextResponse.json({ error: "验证码错误或已过期" }, { status: 400 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ data: { verified: true } });
|
||||
response.cookies.set(
|
||||
HIGH_RISK_ADMIN_PROOF_COOKIE,
|
||||
issueHighRiskAdminProof(
|
||||
proofContext,
|
||||
proofSecret,
|
||||
session.identitySession.token,
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: true,
|
||||
path: "/api/admin",
|
||||
maxAge: HIGH_RISK_ADMIN_PROOF_TTL_MS / 1_000,
|
||||
},
|
||||
);
|
||||
response.cookies.set(HIGH_RISK_ADMIN_CHALLENGE_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: true,
|
||||
path: "/api/admin",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const mutationSchema = z.object({
|
||||
@@ -28,7 +28,7 @@ export async function GET(request: Request) {
|
||||
export async function POST(request: Request){
|
||||
try{
|
||||
const body=mutationSchema.safeParse(await request.json().catch(()=>null)); if(!body.success)return invalidQueryResponse(body.error.flatten());
|
||||
const session=await requireHighRiskAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
|
||||
const session=await requireAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
|
||||
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,body.data.reason,rid]);
|
||||
return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}});
|
||||
}catch(error){return adminErrorResponse(error);}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function AdministratorsResource() {
|
||||
extra={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openAssignment()}>按邮箱分配角色</Button> : <Typography.Text type="secondary">只读权限</Typography.Text>}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
六类系统角色可在此分配和撤销。每次变更都需要邮箱验证码与操作原因;最后一位 Owner 受服务端保护,不能被撤销。
|
||||
六类系统角色可在此分配和撤销。每次变更都需要填写操作原因并写入审计日志;最后一位 Owner 受服务端保护,不能被撤销。
|
||||
</Typography.Paragraph>
|
||||
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="q" label="搜索">
|
||||
@@ -154,10 +154,9 @@ export default function AdministratorsResource() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingAction)}
|
||||
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
|
||||
okText={pendingAction?.action === "revoke" ? "验证并撤销" : "验证并分配"}
|
||||
okText={pendingAction?.action === "revoke" ? "确认撤销" : "确认分配"}
|
||||
danger={pendingAction?.action === "revoke"}
|
||||
confirmLoading={saving}
|
||||
reauthPermission="admin.users.manage_roles"
|
||||
onCancel={() => setPendingAction(null)}
|
||||
onSubmit={submitRoleAction}
|
||||
/>
|
||||
|
||||
@@ -206,9 +206,8 @@ export function SubscriptionsResource() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(selected && extendDays !== null)}
|
||||
title="延长订阅权益"
|
||||
okText="验证并延长"
|
||||
okText="确认延长"
|
||||
confirmLoading={saving}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setExtendDays(null)}
|
||||
onSubmit={extend}
|
||||
/>
|
||||
@@ -218,7 +217,6 @@ export function SubscriptionsResource() {
|
||||
okText="确认撤销"
|
||||
danger
|
||||
confirmLoading={Boolean(revokingId)}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onSubmit={(reason) => revoke(revokeTarget!, reason)}
|
||||
/>
|
||||
@@ -430,7 +428,6 @@ export function OrdersResource() {
|
||||
okText="确认执行"
|
||||
danger={target?.action === "record_refund"}
|
||||
confirmLoading={saving}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setTarget(null)}
|
||||
onSubmit={adjust}
|
||||
/>
|
||||
|
||||
@@ -319,18 +319,16 @@ export default function CodesPage() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingCreate)}
|
||||
title="生成兑换码"
|
||||
okText="验证并生成"
|
||||
okText="确认生成"
|
||||
confirmLoading={createMutation.isPending}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setPendingCreate(null)}
|
||||
onSubmit={submitCreate}
|
||||
/>
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingEdit)}
|
||||
title="编辑未兑换码"
|
||||
okText="验证并保存"
|
||||
okText="确认保存"
|
||||
confirmLoading={updateMutation.isPending}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setPendingEdit(null)}
|
||||
onSubmit={submitEdit}
|
||||
/>
|
||||
@@ -340,7 +338,6 @@ export default function CodesPage() {
|
||||
okText="确认撤销"
|
||||
danger
|
||||
confirmLoading={revoking}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setRevokeRecord(null)}
|
||||
onSubmit={(reason) => revoke(revokeRecord!, reason)}
|
||||
/>
|
||||
|
||||
@@ -162,7 +162,6 @@ export default function FeatureFlagsManagement() {
|
||||
title={`发布功能开关${publishTarget ? `:${publishTarget.flagKey}` : ""}`}
|
||||
okText="确认发布"
|
||||
confirmLoading={Boolean(publishingId)}
|
||||
reauthPermission="ops.flags.write"
|
||||
onCancel={() => setPublishTarget(null)}
|
||||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,6 @@ type MfaStatus = {
|
||||
required: boolean;
|
||||
enrolled: boolean;
|
||||
verified: boolean;
|
||||
highRiskWritesEnabled: boolean;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
@@ -104,7 +103,7 @@ export default function MfaSecurity() {
|
||||
if (!data) return;
|
||||
setStatus(data);
|
||||
setEnrollment(undefined);
|
||||
setNotice("当前管理员会话已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证码。");
|
||||
setNotice("当前管理员会话已完成 MFA 验证。");
|
||||
}
|
||||
|
||||
async function recover({ code }: { code: string }) {
|
||||
@@ -131,8 +130,8 @@ export default function MfaSecurity() {
|
||||
setEnrollment(undefined);
|
||||
setBackupCodes(undefined);
|
||||
setNotice(data.required
|
||||
? "MFA 已禁用。该角色要求 MFA,因此高风险写入现已关闭,重新绑定后才能恢复。"
|
||||
: "MFA 已禁用,当前 MFA 与邮箱重认证证明均已撤销。");
|
||||
? "MFA 已禁用。当前角色要求启用 MFA,可随时重新绑定。"
|
||||
: "MFA 已禁用,当前会话的 MFA 证明已撤销。");
|
||||
}
|
||||
|
||||
if (loading && !status) {
|
||||
@@ -153,7 +152,7 @@ export default function MfaSecurity() {
|
||||
type="warning"
|
||||
showIcon
|
||||
message="当前角色强制要求 MFA"
|
||||
description="在 MFA 绑定完成前,所有高风险写入均默认拒绝。"
|
||||
description="这是账户级安全要求,不会再触发具体管理操作的邮箱验证码复核。"
|
||||
/> : null}
|
||||
|
||||
<Card title="当前状态">
|
||||
@@ -167,11 +166,6 @@ export default function MfaSecurity() {
|
||||
<Descriptions.Item label="当前会话">
|
||||
<Text type={status?.verified ? undefined : "secondary"}>{status?.verified ? "已验证" : "未验证"}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="高风险写入前置能力">
|
||||
<Text type={status?.highRiskWritesEnabled ? undefined : "danger"}>
|
||||
{status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
@@ -208,7 +202,7 @@ export default function MfaSecurity() {
|
||||
</Card> : null}
|
||||
|
||||
{status?.enrolled && !status.verified ? <Card title="验证当前会话">
|
||||
<Paragraph>高权限操作前,先用认证器验证码或一次性恢复码完成真实第二因素。</Paragraph>
|
||||
<Paragraph>可使用认证器验证码或一次性恢复码验证当前管理员会话。</Paragraph>
|
||||
<Radio.Group value={factor} onChange={(event) => setFactor(event.target.value as Factor)}>
|
||||
<Radio.Button value="totp">认证器验证码</Radio.Button>
|
||||
<Radio.Button value="backup">恢复码</Radio.Button>
|
||||
@@ -254,7 +248,7 @@ export default function MfaSecurity() {
|
||||
<Divider />
|
||||
<div>
|
||||
<Title level={4}>禁用 MFA</Title>
|
||||
<Paragraph type="secondary">禁用会轮换 Better Auth 会话,并撤销当前 MFA 与邮箱重认证证明。</Paragraph>
|
||||
<Paragraph type="secondary">禁用会轮换 Better Auth 会话,并撤销当前会话的 MFA 证明。</Paragraph>
|
||||
<Form layout="inline" onFinish={disable}>
|
||||
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
|
||||
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
|
||||
|
||||
@@ -280,9 +280,8 @@ export default function PaymentManagement() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingEpaySettings)}
|
||||
title="保存易支付设置"
|
||||
okText="验证并保存"
|
||||
okText="确认保存"
|
||||
confirmLoading={epaySaving}
|
||||
reauthPermission="billing.adjustments.write"
|
||||
onCancel={() => setPendingEpaySettings(null)}
|
||||
onSubmit={saveEpaySettings}
|
||||
/>
|
||||
|
||||
@@ -280,9 +280,8 @@ export default function ProductManagement() {
|
||||
<ReasonActionModal
|
||||
open={Boolean(pendingSave)}
|
||||
title="保存商品草稿"
|
||||
okText="验证并保存"
|
||||
okText="确认保存"
|
||||
confirmLoading={saving}
|
||||
reauthPermission="billing.products.write"
|
||||
onCancel={() => setPendingSave(null)}
|
||||
onSubmit={save}
|
||||
/>
|
||||
@@ -291,7 +290,6 @@ export default function ProductManagement() {
|
||||
title={`发布商品${publishTarget ? `:${publishTarget.name}` : ""}`}
|
||||
okText="确认发布"
|
||||
confirmLoading={Boolean(publishingId)}
|
||||
reauthPermission="billing.products.publish"
|
||||
onCancel={() => setPublishTarget(null)}
|
||||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||||
/>
|
||||
|
||||
@@ -1,136 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Form, Input, Modal, Space } from "antd";
|
||||
import { Alert, Form, Input, Modal } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AdminPermission } from "@/lib/admin/auth-policy";
|
||||
|
||||
type ReasonActionModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
okText: string;
|
||||
confirmLoading?: boolean;
|
||||
danger?: boolean;
|
||||
reauthPermission?: AdminPermission;
|
||||
confirmLoading?: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (reason: string) => void | Promise<void>;
|
||||
onSubmit: (reason: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type FormValues = { reason: string; otp?: string };
|
||||
|
||||
async function adminSecurityRequest<T>(url: string, body: object): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const value = await response.json().catch(() => null) as {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
} | null;
|
||||
if (response.ok && value?.data) return value.data;
|
||||
throw new Error(
|
||||
typeof value?.error === "string" ? value.error : "安全验证失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
type ReasonFormValues = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function ReasonActionModal({
|
||||
open,
|
||||
title,
|
||||
okText,
|
||||
confirmLoading = false,
|
||||
danger = false,
|
||||
reauthPermission,
|
||||
confirmLoading = false,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ReasonActionModalProps) {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [reauthLoading, setReauthLoading] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string>();
|
||||
const [form] = Form.useForm<ReasonFormValues>();
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string>();
|
||||
|
||||
async function requestOtp() {
|
||||
if (!reauthPermission) return;
|
||||
setReauthLoading(true);
|
||||
setReauthError(undefined);
|
||||
async function submit(values: ReasonFormValues) {
|
||||
setActionError(undefined);
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminSecurityRequest("/api/admin/reauth", {
|
||||
action: "request",
|
||||
permission: reauthPermission,
|
||||
});
|
||||
setOtpSent(true);
|
||||
await onSubmit(values.reason.trim());
|
||||
} catch (error) {
|
||||
setReauthError(error instanceof Error ? error.message : "验证码发送失败");
|
||||
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
|
||||
} finally {
|
||||
setReauthLoading(false);
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit({ reason, otp }: FormValues) {
|
||||
setReauthError(undefined);
|
||||
setReauthLoading(true);
|
||||
try {
|
||||
if (reauthPermission) {
|
||||
await adminSecurityRequest("/api/admin/reauth", {
|
||||
action: "verify",
|
||||
permission: reauthPermission,
|
||||
otp,
|
||||
});
|
||||
}
|
||||
await onSubmit(reason.trim());
|
||||
} catch (error) {
|
||||
setReauthError(error instanceof Error ? error.message : "操作失败,请稍后再试");
|
||||
} finally {
|
||||
setReauthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal
|
||||
title={title}
|
||||
open={open}
|
||||
okText={okText}
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmLoading || reauthLoading}
|
||||
okButtonProps={{
|
||||
danger,
|
||||
disabled: Boolean(reauthPermission && !otpSent),
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={onCancel}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) form.resetFields();
|
||||
setOtpSent(false);
|
||||
setReauthError(undefined);
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={submit}>
|
||||
<Form.Item name="reason" label="操作原因" rules={[{ required: true, whitespace: true }, { max: 500 }]}>
|
||||
<Input.TextArea rows={3} showCount maxLength={500} autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
{reauthPermission ? <Form.Item
|
||||
label="邮箱验证码"
|
||||
extra="验证码发送到当前登录管理员邮箱,5 分钟内有效,且只授权本次操作权限。"
|
||||
required
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item
|
||||
name="otp"
|
||||
noStyle
|
||||
rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}
|
||||
>
|
||||
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="请输入 6 位验证码" />
|
||||
</Form.Item>
|
||||
<Button type="default" loading={reauthLoading} onClick={() => void requestOtp()}>
|
||||
{otpSent ? "重新发送" : "发送验证码"}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item> : null}
|
||||
|
||||
{reauthError ? <Alert type="error" showIcon message={reauthError} /> : null}
|
||||
</Form>
|
||||
</Modal>;
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
okText={okText}
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger }}
|
||||
confirmLoading={confirmLoading || actionLoading}
|
||||
onCancel={onCancel}
|
||||
onOk={() => form.submit()}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) form.resetFields();
|
||||
setActionError(undefined);
|
||||
}}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false} onFinish={submit}>
|
||||
<Form.Item
|
||||
label="操作原因"
|
||||
name="reason"
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: "请输入操作原因" },
|
||||
{ max: 500, message: "操作原因最多 500 字" },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount autoFocus />
|
||||
</Form.Item>
|
||||
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,10 +39,6 @@ export type AdminAccessResult =
|
||||
|
||||
export const ADMIN_MFA_PROOF_COOKIE = "jyotisha-admin.mfa";
|
||||
export const ADMIN_MFA_PROOF_TTL_MS = 10 * 60 * 1_000;
|
||||
export const HIGH_RISK_ADMIN_CHALLENGE_COOKIE = "jyotisha-admin.reauth-challenge";
|
||||
export const HIGH_RISK_ADMIN_CHALLENGE_TTL_MS = 5 * 60 * 1_000;
|
||||
export const HIGH_RISK_ADMIN_PROOF_COOKIE = "jyotisha-admin.reauth";
|
||||
export const HIGH_RISK_ADMIN_PROOF_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
type AdminMfaProofContext = {
|
||||
userId: string;
|
||||
@@ -56,21 +52,10 @@ type AdminMfaProofClaims = AdminMfaProofContext & {
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type HighRiskAdminProofContext = AdminMfaProofContext & {
|
||||
permission: AdminPermission;
|
||||
};
|
||||
|
||||
type HighRiskAdminProofClaims = HighRiskAdminProofContext & {
|
||||
version: 1;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type AdminMfaStatus = {
|
||||
required: boolean;
|
||||
enrolled: boolean;
|
||||
verified: boolean;
|
||||
highRiskWritesEnabled: boolean;
|
||||
};
|
||||
|
||||
export function authorizeAdminAccess(
|
||||
@@ -167,7 +152,6 @@ export function resolveAdminMfaStatus(
|
||||
required,
|
||||
enrolled,
|
||||
verified: currentSessionVerified,
|
||||
highRiskWritesEnabled: !required || currentSessionVerified,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +172,7 @@ function signProof(
|
||||
|
||||
function encodeProof(
|
||||
purpose: string,
|
||||
claims: AdminMfaProofClaims | HighRiskAdminProofClaims,
|
||||
claims: AdminMfaProofClaims,
|
||||
proofSecret: string,
|
||||
sessionToken: string,
|
||||
): string {
|
||||
@@ -255,81 +239,3 @@ export function verifyAdminMfaProof(
|
||||
&& claims.issuedAt! <= now
|
||||
&& now < claims.expiresAt!;
|
||||
}
|
||||
|
||||
export function issueHighRiskAdminChallenge(
|
||||
context: HighRiskAdminProofContext,
|
||||
proofSecret: string,
|
||||
sessionToken: string,
|
||||
now = Date.now(),
|
||||
): string {
|
||||
return encodeProof("jyotisha-admin-reauth-challenge-v1", {
|
||||
version: 1,
|
||||
...context,
|
||||
issuedAt: now,
|
||||
expiresAt: now + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
|
||||
} satisfies HighRiskAdminProofClaims, proofSecret, sessionToken);
|
||||
}
|
||||
|
||||
export function verifyHighRiskAdminChallenge(
|
||||
proof: string | undefined,
|
||||
context: HighRiskAdminProofContext,
|
||||
proofSecret: string,
|
||||
sessionToken: string,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const claims = decodeProof<HighRiskAdminProofClaims>(
|
||||
proof,
|
||||
"jyotisha-admin-reauth-challenge-v1",
|
||||
proofSecret,
|
||||
sessionToken,
|
||||
);
|
||||
return claims?.version === 1
|
||||
&& claims.userId === context.userId
|
||||
&& claims.sessionId === context.sessionId
|
||||
&& claims.permission === context.permission
|
||||
&& claims.origin === context.origin
|
||||
&& Number.isSafeInteger(claims.issuedAt)
|
||||
&& Number.isSafeInteger(claims.expiresAt)
|
||||
&& claims.expiresAt! - claims.issuedAt! === HIGH_RISK_ADMIN_CHALLENGE_TTL_MS
|
||||
&& claims.issuedAt! <= now
|
||||
&& now < claims.expiresAt!;
|
||||
}
|
||||
|
||||
export function issueHighRiskAdminProof(
|
||||
context: HighRiskAdminProofContext,
|
||||
proofSecret: string,
|
||||
sessionToken: string,
|
||||
now = Date.now(),
|
||||
): string {
|
||||
return encodeProof("jyotisha-admin-reauth-v1", {
|
||||
version: 1,
|
||||
...context,
|
||||
issuedAt: now,
|
||||
expiresAt: now + HIGH_RISK_ADMIN_PROOF_TTL_MS,
|
||||
} satisfies HighRiskAdminProofClaims, proofSecret, sessionToken);
|
||||
}
|
||||
|
||||
export function verifyHighRiskAdminProof(
|
||||
proof: string | undefined,
|
||||
context: HighRiskAdminProofContext,
|
||||
proofSecret: string,
|
||||
sessionToken: string,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const claims = decodeProof<HighRiskAdminProofClaims>(
|
||||
proof,
|
||||
"jyotisha-admin-reauth-v1",
|
||||
proofSecret,
|
||||
sessionToken,
|
||||
);
|
||||
return claims?.version === 1
|
||||
&& claims.userId === context.userId
|
||||
&& claims.sessionId === context.sessionId
|
||||
&& claims.permission === context.permission
|
||||
&& claims.origin === context.origin
|
||||
&& Number.isSafeInteger(claims.issuedAt)
|
||||
&& Number.isSafeInteger(claims.expiresAt)
|
||||
&& claims.expiresAt! - claims.issuedAt! === HIGH_RISK_ADMIN_PROOF_TTL_MS
|
||||
&& claims.issuedAt! <= now
|
||||
&& now < claims.expiresAt!;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
} from "./auth";
|
||||
import {
|
||||
ADMIN_MFA_PROOF_COOKIE,
|
||||
HIGH_RISK_ADMIN_PROOF_COOKIE,
|
||||
isTrustedAdminMutationRequest,
|
||||
resolveAdminMfaStatus,
|
||||
verifyAdminMfaProof,
|
||||
verifyHighRiskAdminProof,
|
||||
type AdminMfaStatus,
|
||||
} from "./auth-policy";
|
||||
import { adminErrorResponse } from "./admin-error-response";
|
||||
@@ -86,30 +84,6 @@ export function readAdminMfaStatus(
|
||||
return resolveAdminMfaStatus(session.requiresMfa, enrolled, verified);
|
||||
}
|
||||
|
||||
export async function requireHighRiskAdminMutation(
|
||||
request: Request,
|
||||
permission: AdminPermission,
|
||||
): Promise<AdminSession> {
|
||||
const session = await requireAdminMutation(request, permission);
|
||||
|
||||
const origin = new URL(request.url).origin;
|
||||
const valid = verifyHighRiskAdminProof(
|
||||
requestCookie(request, HIGH_RISK_ADMIN_PROOF_COOKIE),
|
||||
{
|
||||
userId: session.user.id,
|
||||
sessionId: session.identitySession.id,
|
||||
permission,
|
||||
origin,
|
||||
},
|
||||
adminProofSigningSecret(),
|
||||
session.identitySession.token,
|
||||
);
|
||||
if (!valid) {
|
||||
throw new AdminAuthorizationError("请先使用邮箱验证码重新认证", 403);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function invalidQueryResponse(details?: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: "查询参数不正确", ...(details ? { details } : {}) },
|
||||
|
||||
@@ -16,7 +16,8 @@ test("admin account reset stays owner-only and explicit without email reauth", (
|
||||
assert.match(users, /permissions\.includes\("admin\.users\.manage_roles"\)/);
|
||||
assert.doesNotMatch(users, /reauthPermission=/);
|
||||
assert.match(users, /登录身份、管理员角色、积分及账务审计记录会保留/);
|
||||
assert.match(administratorsRoute, /requireHighRiskAdminMutation\(\s*request,\s*"admin\.users\.manage_roles",?\s*\)/);
|
||||
assert.match(administratorsRoute, /requireAdminMutation\(\s*request,\s*"admin\.users\.manage_roles",?\s*\)/);
|
||||
assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/);
|
||||
});
|
||||
|
||||
test("database reset mirrors the existing staging reset boundary and audits it", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { GET as getAdminUnavailable } from "../src/app/admin-unavailable/route.ts";
|
||||
@@ -23,7 +23,6 @@ const ownerRecoveryMigration = readFileSync(new URL("../db/migrations/2026080701
|
||||
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");
|
||||
const reauthRoute = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8");
|
||||
const mfaRoute = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8");
|
||||
const mfaSecurity = readFileSync(new URL("../src/components/admin/mfa-security.tsx", import.meta.url), "utf8");
|
||||
const reasonActionModal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
|
||||
@@ -51,8 +50,10 @@ test("admin APIs use persisted Better Auth roles with admin-only boundaries", ()
|
||||
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
|
||||
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);
|
||||
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
|
||||
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(codesRoute, /requireHighRiskAdminMutation/);
|
||||
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
|
||||
});
|
||||
|
||||
test("self-hosted account entry uses the database permission graph", () => {
|
||||
@@ -230,20 +231,15 @@ test("Refine dependencies and same-origin admin data provider are present", () =
|
||||
assert.doesNotMatch(providers, /https?:\/\//);
|
||||
});
|
||||
|
||||
test("administrator writes require scoped email OTP reauthentication without mandatory MFA", () => {
|
||||
test("administrator writes keep permission and origin checks without operation-level email OTP", () => {
|
||||
assert.match(adminHttp, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
|
||||
assert.match(administratorsRoute, /requireHighRiskAdminMutation\(request, "admin\.users\.manage_roles"\)/);
|
||||
assert.match(adminHttp, /requireAdminMutation\(request, permission\)[\s\S]*verifyHighRiskAdminProof/);
|
||||
assert.doesNotMatch(adminHttp, /requireAdminMfaIfRequired/);
|
||||
assert.match(reauthRoute, /requireAdminMutation\(request, parsed\.data\.permission\)[\s\S]*sendVerificationOTP/);
|
||||
assert.doesNotMatch(reauthRoute, /requireAdminMfaIfRequired/);
|
||||
assert.match(reauthRoute, /sendVerificationOTP/);
|
||||
assert.match(reauthRoute, /verifyEmailOTP/);
|
||||
assert.match(reauthRoute, /httpOnly: true/);
|
||||
assert.match(reasonActionModal, /action: "request"/);
|
||||
assert.match(reasonActionModal, /action: "verify"/);
|
||||
assert.match(reasonActionModal, /邮箱验证码/);
|
||||
assert.doesNotMatch(reasonActionModal, /\/api\/admin\/mfa|\/admin\/security|MfaStatus|mfaFactor|mfaReady/);
|
||||
assert.match(administratorsRoute, /requireAdminMutation\(request, "admin\.users\.manage_roles"\)/);
|
||||
assert.doesNotMatch(adminHttp, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
|
||||
assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/);
|
||||
assert.equal(existsSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url)), false);
|
||||
assert.match(reasonActionModal, /label="操作原因"/);
|
||||
assert.match(reasonActionModal, /await onSubmit\(values\.reason\.trim\(\)\)/);
|
||||
assert.doesNotMatch(reasonActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
|
||||
|
||||
assert.match(authFactory, /twoFactor\(/);
|
||||
assert.match(authFactory, /schema: identityModelMapping\.twoFactor/);
|
||||
@@ -255,7 +251,6 @@ test("administrator writes require scoped email OTP reauthentication without man
|
||||
assert.match(mfaRoute, /disableTwoFactor/);
|
||||
assert.match(mfaSecurity, /\/api\/admin\/mfa/);
|
||||
assert.doesNotMatch(administratorsRoute, /ADMIN_MFA_CAPABLE|process\.env/);
|
||||
assert.doesNotMatch(reauthRoute, /ADMIN_MFA_CAPABLE|process\.env/);
|
||||
});
|
||||
|
||||
test("customer birth data uses a narrow permission, is masked by default, and audits sensitive reads", () => {
|
||||
|
||||
@@ -29,20 +29,26 @@ test("MFA proof is server-signed, short-lived, and bound to the current user, se
|
||||
assert.equal(verifyAdminMfaProof(proof, context, proofSecret, sessionToken, now + ADMIN_MFA_PROOF_TTL_MS), false);
|
||||
});
|
||||
|
||||
test("highRiskWritesEnabled means the current session has the MFA prerequisite, not email proof", () => {
|
||||
test("MFA status only reports account-level enrollment and current-session verification", () => {
|
||||
assert.deepEqual(resolveAdminMfaStatus(false, false, false), {
|
||||
required: false,
|
||||
enrolled: false,
|
||||
verified: false,
|
||||
highRiskWritesEnabled: true,
|
||||
});
|
||||
assert.equal(resolveAdminMfaStatus(true, false, false).highRiskWritesEnabled, false);
|
||||
assert.equal(resolveAdminMfaStatus(true, true, false).highRiskWritesEnabled, false);
|
||||
assert.deepEqual(resolveAdminMfaStatus(true, false, false), {
|
||||
required: true,
|
||||
enrolled: false,
|
||||
verified: false,
|
||||
});
|
||||
assert.deepEqual(resolveAdminMfaStatus(true, true, false), {
|
||||
required: true,
|
||||
enrolled: true,
|
||||
verified: false,
|
||||
});
|
||||
assert.deepEqual(resolveAdminMfaStatus(true, true, true), {
|
||||
required: true,
|
||||
enrolled: true,
|
||||
verified: true,
|
||||
highRiskWritesEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,24 +68,20 @@ test("MFA API keeps native Better Auth enrollment, recovery, rotation, and proof
|
||||
assert.match(route, /copyNativeCookies\(nativeResponse, response\)/);
|
||||
assert.match(route, /requireVerifiedMfa\(request, session\)/);
|
||||
assert.match(route, /ADMIN_MFA_PROOF_COOKIE/);
|
||||
assert.match(route, /HIGH_RISK_ADMIN_PROOF_COOKIE/);
|
||||
assert.doesNotMatch(route, /HIGH_RISK_ADMIN_PROOF_COOKIE/);
|
||||
assert.match(route, /adminProofSigningSecret\(\)/);
|
||||
assert.doesNotMatch(route, /console\.|logger|log.*(?:seed|secret|backup|totp)|(?:seed|secret|backup|totp).*log/i);
|
||||
assert.match(migration, /revoke all on table identity\.two_factors[\s\S]*admin_runtime/);
|
||||
assert.match(migration, /grant select, insert, update, delete on table identity\.two_factors[\s\S]*identity_runtime/);
|
||||
});
|
||||
|
||||
test("high-risk UI uses permission-scoped email OTP without requiring MFA", () => {
|
||||
test("business confirmation UI requires only an audit reason while account MFA remains separate", () => {
|
||||
const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
|
||||
const reauth = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8");
|
||||
const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(modal, /action: "request"/);
|
||||
assert.match(modal, /action: "verify"/);
|
||||
assert.match(modal, /邮箱验证码/);
|
||||
assert.doesNotMatch(modal, /\/api\/admin\/mfa|\/admin\/security|MfaStatus|mfaFactor|mfaReady/);
|
||||
assert.match(reauth, /requireAdminMutation\(request, parsed\.data\.permission\)[\s\S]*getIdentityEmailOtpApi/);
|
||||
assert.doesNotMatch(reauth, /requireAdminMfaIfRequired/);
|
||||
assert.match(http, /requireAdminMutation\(request, permission\)[\s\S]*verifyHighRiskAdminProof/);
|
||||
assert.doesNotMatch(http, /requireAdminMfaIfRequired/);
|
||||
assert.match(modal, /label="操作原因"/);
|
||||
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
|
||||
assert.doesNotMatch(modal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
|
||||
assert.match(http, /requireAdminMutation/);
|
||||
assert.doesNotMatch(http, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
|
||||
function source(path: string) {
|
||||
return readFileSync(new URL(path, root), "utf8");
|
||||
}
|
||||
|
||||
function sourceFiles(path: string): string[] {
|
||||
const directory = new URL(path, root);
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const child = join(directory.pathname, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return sourceFiles(`${path}/${entry.name}`);
|
||||
}
|
||||
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
|
||||
});
|
||||
}
|
||||
|
||||
test("operation-level admin email reauthentication is removed from routes and UI", () => {
|
||||
assert.equal(existsSync(new URL("src/app/api/admin/reauth/route.ts", root)), false);
|
||||
|
||||
const adminSources = [
|
||||
...sourceFiles("src/app/api/admin"),
|
||||
...sourceFiles("src/components/admin"),
|
||||
...sourceFiles("src/lib/admin"),
|
||||
];
|
||||
for (const file of adminSources) {
|
||||
const contents = readFileSync(file, "utf8");
|
||||
assert.doesNotMatch(
|
||||
contents,
|
||||
/requireHighRiskAdminMutation|reauthPermission|\/api\/admin\/reauth|HIGH_RISK_ADMIN|issueHighRisk|verifyHighRisk/,
|
||||
file,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("admin mutations retain session, permission, trusted-origin, reason, request-id, and audit boundaries", () => {
|
||||
const http = source("src/lib/admin/http.ts");
|
||||
const codesRoute = source("src/app/api/admin/codes/route.ts");
|
||||
const codesUi = source("src/components/admin/codes-resource.tsx");
|
||||
const modal = source("src/components/admin/reason-action-modal.tsx");
|
||||
|
||||
assert.match(http, /requirePermission\(permission, request\.headers\)/);
|
||||
assert.match(http, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
|
||||
assert.match(codesRoute, /requireAdminMutation\([\s\S]*"billing\.adjustments\.write"/);
|
||||
assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
|
||||
assert.match(codesRoute, /requestId\(request\)/);
|
||||
assert.match(codesRoute, /admin_create_redemption_codes/);
|
||||
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
|
||||
assert.match(codesUi, /okText="确认生成"/);
|
||||
assert.match(modal, /label="操作原因"/);
|
||||
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
|
||||
});
|
||||
|
||||
test("account-level TOTP MFA and customer login email OTP remain available", () => {
|
||||
const mfaRoute = source("src/app/api/admin/mfa/route.ts");
|
||||
const mfaUi = source("src/components/admin/mfa-security.tsx");
|
||||
const loginUi = source("src/components/email-otp-login.tsx");
|
||||
|
||||
assert.match(mfaRoute, /verifyTOTP|verifyBackupCode/);
|
||||
assert.match(mfaUi, /认证器验证码/);
|
||||
assert.match(loginUi, /邮箱验证码登录/);
|
||||
assert.match(loginUi, /验证并登录/);
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
authorizeAdminAccess,
|
||||
HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
|
||||
issueHighRiskAdminChallenge,
|
||||
issueHighRiskAdminProof,
|
||||
isSameOriginAdminMutation,
|
||||
verifyHighRiskAdminChallenge,
|
||||
verifyHighRiskAdminProof,
|
||||
} from "../src/lib/admin/auth-policy.ts";
|
||||
import type { IdentityUser } from "../src/modules/identity/contracts.ts";
|
||||
|
||||
const now = 1_786_000_000_000;
|
||||
const context = {
|
||||
userId: "11111111-1111-4111-8111-111111111111",
|
||||
sessionId: "22222222-2222-4222-8222-222222222222",
|
||||
permission: "admin.users.manage_roles" as const,
|
||||
origin: "https://admin.staging.jyotisha.chat",
|
||||
};
|
||||
const sessionToken = "better-auth-session-token-held-server-side";
|
||||
const proofSecret = "admin-proof-secret-held-only-by-the-server";
|
||||
|
||||
function user(): IdentityUser {
|
||||
return {
|
||||
id: context.userId,
|
||||
email: "admin@example.com",
|
||||
emailVerified: true,
|
||||
name: "Admin",
|
||||
image: null,
|
||||
role: ["user"],
|
||||
twoFactorEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
test("email OTP challenge is short-lived and bound to the requested permission and session", () => {
|
||||
const challenge = issueHighRiskAdminChallenge(context, proofSecret, sessionToken, now);
|
||||
assert.equal(
|
||||
verifyHighRiskAdminChallenge(challenge, context, proofSecret, sessionToken, now + 1_000),
|
||||
true,
|
||||
);
|
||||
assert.equal(verifyHighRiskAdminChallenge(
|
||||
challenge,
|
||||
{ ...context, permission: "billing.products.publish" },
|
||||
proofSecret,
|
||||
sessionToken,
|
||||
now + 1_000,
|
||||
), false);
|
||||
assert.equal(verifyHighRiskAdminChallenge(
|
||||
challenge,
|
||||
context,
|
||||
proofSecret,
|
||||
"rotated-session-token",
|
||||
now + 1_000,
|
||||
), false);
|
||||
assert.equal(verifyHighRiskAdminChallenge(
|
||||
challenge,
|
||||
context,
|
||||
proofSecret,
|
||||
sessionToken,
|
||||
now + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
|
||||
), false);
|
||||
});
|
||||
|
||||
test("high-risk proof succeeds only for its live Better Auth session", () => {
|
||||
const proof = issueHighRiskAdminProof(context, proofSecret, sessionToken, now);
|
||||
assert.equal(verifyHighRiskAdminProof(proof, context, proofSecret, sessionToken, now + 1_000), true);
|
||||
assert.equal(verifyHighRiskAdminProof(proof, { ...context, sessionId: "wrong-session" }, proofSecret, sessionToken, now + 1_000), false);
|
||||
assert.equal(verifyHighRiskAdminProof(
|
||||
proof,
|
||||
{ ...context, permission: "billing.products.publish" },
|
||||
proofSecret,
|
||||
sessionToken,
|
||||
now + 1_000,
|
||||
), false);
|
||||
assert.equal(verifyHighRiskAdminProof(proof, context, "wrong-server-secret-that-is-long-enough", sessionToken, now + 1_000), false);
|
||||
assert.equal(verifyHighRiskAdminProof(proof, context, proofSecret, sessionToken, now + 300_000), false);
|
||||
});
|
||||
|
||||
test("high-risk authorization rejects missing permission and cross-origin requests", () => {
|
||||
assert.deepEqual(authorizeAdminAccess(user(), [], context.permission), { allowed: false, status: 403 });
|
||||
assert.equal(isSameOriginAdminMutation(context.origin, `${context.origin}/api/admin/reauth`), true);
|
||||
assert.equal(isSameOriginAdminMutation("https://evil.example", `${context.origin}/api/admin/reauth`), false);
|
||||
assert.equal(isSameOriginAdminMutation(null, `${context.origin}/api/admin/reauth`), false);
|
||||
});
|
||||
|
||||
test("reauth route consumes Better Auth email OTP and sets a scoped HttpOnly proof", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8");
|
||||
const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(route, /sendVerificationOTP/);
|
||||
assert.match(route, /verifyEmailOTP/);
|
||||
assert.match(route, /type: "email-verification"/);
|
||||
assert.match(route, /issueHighRiskAdminChallenge/);
|
||||
assert.match(route, /verifyHighRiskAdminChallenge/);
|
||||
assert.match(route, /HIGH_RISK_ADMIN_CHALLENGE_COOKIE/);
|
||||
assert.match(route, /verifyHighRiskAdminChallenge[\s\S]*verifyEmailOTP/);
|
||||
assert.match(route, /httpOnly: true/);
|
||||
assert.match(route, /sameSite: "strict"/);
|
||||
assert.match(route, /secure: true/);
|
||||
assert.match(route, /path: "\/api\/admin"/);
|
||||
assert.match(route, /adminProofSigningSecret\(\)/);
|
||||
assert.doesNotMatch(route, /console\.|otp.*log|log.*otp/i);
|
||||
assert.match(modal, /reauthPermission/);
|
||||
assert.match(modal, /action: "request"/);
|
||||
assert.match(modal, /action: "verify"/);
|
||||
assert.match(modal, /邮箱验证码/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
|
||||
const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
|
||||
const highRiskMappings = [
|
||||
const mutationMappings = [
|
||||
{
|
||||
name: "商品保存",
|
||||
ui: "src/components/admin/product-management.tsx",
|
||||
@@ -61,15 +61,16 @@ const highRiskMappings = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
test("high-risk UI reauth permissions match their API guards", async (t) => {
|
||||
for (const mapping of highRiskMappings) {
|
||||
test("admin mutation UI permissions match ordinary API guards without operation reauth", async (t) => {
|
||||
for (const mapping of mutationMappings) {
|
||||
await t.test(mapping.name, () => {
|
||||
const ui = source(mapping.ui);
|
||||
const api = source(mapping.api);
|
||||
assert.match(ui, /ReasonActionModal/);
|
||||
assert.match(ui, new RegExp(`reauthPermission=[\\s\\S]{0,80}["']${mapping.permission.replaceAll(".", "\\.")}["']|reauthPermission="${mapping.permission.replaceAll(".", "\\.")}"`));
|
||||
assert.match(api, new RegExp(`requireHighRiskAdminMutation\\(\\s*request,\\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\\s*\\)`));
|
||||
assert.match(ui, new RegExp(`permissions\\.includes\\(["']${mapping.permission.replaceAll(".", "\\.")}["']\\)`));
|
||||
assert.doesNotMatch(ui, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
|
||||
assert.match(api, new RegExp(String.raw`requireAdminMutation\(\s*request,\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\s*\)`));
|
||||
assert.doesNotMatch(api, /requireHighRiskAdminMutation/);
|
||||
assert.match(ui, new RegExp(String.raw`permissions\.includes\(["']${mapping.permission.replaceAll(".", "\\.")}["']\)`));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -122,9 +123,10 @@ test("admin request failures are real Error instances and reason modal keeps fai
|
||||
/Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/,
|
||||
);
|
||||
assert.doesNotMatch(providers, /throw \{ message, statusCode/);
|
||||
assert.match(modal, /catch \(error\)[\s\S]*setReauthError\(error instanceof Error \? error\.message/);
|
||||
assert.match(modal, /catch \(error\)[\s\S]*setActionError\(error instanceof Error \? error\.message/);
|
||||
assert.match(modal, /name="reason"[\s\S]*required: true, whitespace: true/);
|
||||
assert.match(modal, /await onSubmit\(reason\.trim\(\)\)/);
|
||||
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
|
||||
assert.doesNotMatch(modal, /reauth|邮箱验证码|发送验证码/);
|
||||
});
|
||||
|
||||
test("customer list is always masked and a single explicit reveal is audited each time", () => {
|
||||
|
||||
@@ -22,7 +22,8 @@ test("admin surfaces await database-backed administrator checks", () => {
|
||||
assert.match(adminSource, /export async function isAdminUser/);
|
||||
assert.match(adminSource, /admin_has_permission\(\$1, 'admin\.access'\)/);
|
||||
assert.match(sessionSource, /await requirePermission\("admin\.access"\)/);
|
||||
assert.match(codesSource, /await requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
|
||||
assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
|
||||
assert.doesNotMatch(codesSource, /requireHighRiskAdminMutation/);
|
||||
assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/);
|
||||
assert.match(accountSource, /isAdmin,/);
|
||||
});
|
||||
|
||||
@@ -124,7 +124,8 @@ test("公网网关解析拒绝混入私网地址且每次校验只解析一次",
|
||||
|
||||
test("管理员 API 不回显任何密钥并强制首次显式录入", () => {
|
||||
assert.match(route, /requirePermission\("billing\.orders\.read"\)/);
|
||||
assert.match(route, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
||||
assert.match(route, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
||||
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
|
||||
assert.match(route, /\.strict\(\)/);
|
||||
assert.match(route, /crypto\.randomUUID\(\)/);
|
||||
assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/);
|
||||
|
||||
@@ -38,15 +38,18 @@ test("existing payment orders remain queryable and settleable when subscriptions
|
||||
assert.match(notifyRoute, /createEpayNotifyHandler[\s\S]*settle_order/);
|
||||
});
|
||||
|
||||
test("billing and operations high-risk writes use the shared mutation guard", () => {
|
||||
assert.match(productsRoute, /requireHighRiskAdminMutation\(request, permission\)/);
|
||||
test("billing and operations writes use the shared ordinary mutation guard", () => {
|
||||
assert.match(productsRoute, /requireAdminMutation\(request, permission\)/);
|
||||
assert.match(productsRoute, /body\.data\.reason[\s\S]*requestId/);
|
||||
assert.match(epaySettingsRoute, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
||||
assert.match(epaySettingsRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
|
||||
assert.match(epaySettingsRoute, /admin_save_epay_settings/);
|
||||
assert.match(subscriptionsRoute, /requireHighRiskAdminMutation\(request,"billing\.adjustments\.write"\)/);
|
||||
assert.match(subscriptionsRoute, /requireAdminMutation\(request,"billing\.adjustments\.write"\)/);
|
||||
assert.match(subscriptionsRoute, /body\.data\.reason,rid/);
|
||||
assert.match(featureFlagsRoute, /action==="publish"\?await requireHighRiskAdminMutation\(request,"ops\.flags\.write"\):await requireAdminMutation/);
|
||||
assert.match(featureFlagsRoute, /requireAdminMutation\(request,"ops\.flags\.write"\)/);
|
||||
assert.match(featureFlagsRoute, /admin_publish_feature_flag[\s\S]*b\.data\.reason,rid/);
|
||||
for (const route of [productsRoute, epaySettingsRoute, subscriptionsRoute, featureFlagsRoute]) {
|
||||
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
|
||||
}
|
||||
});
|
||||
|
||||
test("model provider changes and release mutations use the ordinary admin guard", () => {
|
||||
@@ -71,10 +74,10 @@ test("self-hosted payment catalog uses simple queries and immutable product snap
|
||||
assert.match(createRoute, /product_snapshot:\s*productSnapshot/);
|
||||
});
|
||||
|
||||
test("order adjustments require reauth, reason, version, idempotency request id, and the domain RPC", () => {
|
||||
test("order adjustments require permission, reason, version, idempotency request id, and the domain RPC", () => {
|
||||
assert.match(
|
||||
ordersRoute,
|
||||
/requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/,
|
||||
/requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/,
|
||||
);
|
||||
assert.match(ordersRoute, /expectedVersion:\s*z\.number\(\)\.int\(\)\.min\(0\)/);
|
||||
assert.match(ordersRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
|
||||
@@ -85,13 +88,14 @@ test("order adjustments require reauth, reason, version, idempotency request id,
|
||||
assert.match(billingOperationsUi, /retry_grant/);
|
||||
assert.match(billingOperationsUi, /compensate/);
|
||||
assert.match(billingOperationsUi, /record_refund/);
|
||||
assert.match(billingOperationsUi, /reauthPermission="billing\.adjustments\.write"/);
|
||||
assert.doesNotMatch(billingOperationsUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
|
||||
assert.match(billingOperationsUi, /不调用支付网关|仅记录账务/);
|
||||
});
|
||||
|
||||
test("every redemption-code write requires a reason and forwards it to the audited RPC", () => {
|
||||
assert.match(codesRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
|
||||
assert.equal((codeRoute.match(/requireHighRiskAdminMutation\(/g) ?? []).length, 2);
|
||||
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
|
||||
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
|
||||
assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
|
||||
assert.match(codesRoute, /p_reason:\s*parsed\.data\.reason/);
|
||||
assert.match(codeRoute, /const revokeCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
|
||||
@@ -104,7 +108,7 @@ test("every redemption-code write requires a reason and forwards it to the audit
|
||||
assert.match(codesHelper, /public\.admin_revoke_redemption_code\(/);
|
||||
assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/);
|
||||
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
|
||||
assert.equal((codesUi.match(/reauthPermission="billing\.adjustments\.write"/g) ?? []).length, 3);
|
||||
assert.doesNotMatch(codesUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
|
||||
assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onSubmit=\{submitCreate\}/);
|
||||
assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onSubmit=\{submitEdit\}/);
|
||||
assert.match(codesUi, /<ReasonActionModal[\s\S]*onSubmit=\{\(reason\) => revoke\(revokeRecord!, reason\)\}/);
|
||||
|
||||
Reference in New Issue
Block a user