feat: add platform admin permissions

This commit is contained in:
Codex
2026-06-30 07:50:02 +08:00
parent 51f8cbdec8
commit 780feee4f8
20 changed files with 392 additions and 63 deletions

View File

@@ -17,6 +17,7 @@ export interface SessionIdentity {
sessionExpiresAt: string;
authSource: 'app_session' | 'supabase_jwt';
authUserId: string | null;
platformPermissions: Record<string, unknown>;
}
interface RequestAuthState {
@@ -50,7 +51,8 @@ export async function findUserBySessionToken(token: string) {
u.primary_role as "primaryRole", u.created_at as "createdAt",
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt",
'app_session'::text as "authSource",
u.auth_user_id as "authUserId"
u.auth_user_id as "authUserId",
u.platform_permissions as "platformPermissions"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
@@ -135,7 +137,8 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId"
u.auth_user_id as "authUserId",
u.platform_permissions as "platformPermissions"
from public.platform_users u
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
where u.auth_user_id = $1::uuid
@@ -167,7 +170,8 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId"
u.auth_user_id as "authUserId",
u.platform_permissions as "platformPermissions"
from public.platform_users u
join public.tenant_memberships tm on tm.user_id = u.id
where u.auth_user_id = $1::uuid

View File

@@ -147,9 +147,26 @@ export function optionalStringArray(body: JsonObject, key: string): string[] {
return value.map(item => String(item)).filter(Boolean);
}
export async function requirePlatformAdmin(ctx: RequestContext) {
function platformPermissionAllowed(permissions: Record<string, unknown> | null | undefined, permission: string) {
if (!permission) return true;
if (!permissions || typeof permissions !== 'object') return false;
if (permissions['*'] === true) return true;
if (permissions[permission] === true) return true;
const parts = permission.split(':');
for (let i = parts.length - 1; i >= 1; i -= 1) {
const wildcard = `${parts.slice(0, i).join(':')}:*`;
if (permissions[wildcard] === true) return true;
}
return false;
}
export async function requirePlatformAdmin(ctx: RequestContext, permission = '') {
const auth = await hydrateRequestAuth(ctx);
if (auth.session?.primaryRole === 'platform_admin') return;
if (auth.session?.primaryRole === 'platform_admin') {
if (!permission || platformPermissionAllowed(auth.session.platformPermissions, permission)) return;
throw new HttpError(403, `Platform permission ${permission} is required`, 'PLATFORM_PERMISSION_REQUIRED');
}
if (auth.bearerToken) {
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
}

View File

@@ -16,6 +16,7 @@ import {
platformDunningNotificationChannelsRoute,
platformDunningNotificationEventsRoute,
platformOverviewRoute,
platformPermissionsRoute,
platformPlansRoute,
platformQuestionBanksRoute,
processOverdueInvoicesRoute,
@@ -35,6 +36,7 @@ import {
} from './routes.js';
export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/permissions', platformPermissionsRoute],
['GET', '/api/platform-admin/overview', platformOverviewRoute],
['GET', '/api/platform-admin/plans', platformPlansRoute],
['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute],

View File

@@ -58,6 +58,28 @@ const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical'])
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
const PLATFORM_PERMISSION_CATALOG = [
{ key: 'platform:overview:read', group: 'overview', label: '平台概览' },
{ key: 'platform:tenant:read', group: 'tenant', label: '查看租户' },
{ key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' },
{ key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' },
{ key: 'platform:tenant:billing_profile', group: 'tenant', label: '维护租户账务资料' },
{ key: 'platform:plan:read', group: 'billing', label: '查看 SaaS 套餐' },
{ key: 'platform:billing:read', group: 'billing', label: '查看平台账务' },
{ key: 'platform:billing:write', group: 'billing', label: '创建订阅/账单' },
{ key: 'platform:billing:payment', group: 'billing', label: '确认服务费收款' },
{ key: 'platform:billing:dunning', group: 'billing', label: '处理逾期催缴' },
{ key: 'platform:billing:notification', group: 'billing', label: '维护催缴通知' },
{ key: 'platform:usage:read', group: 'usage', label: '查看租户用量' },
{ key: 'platform:usage:write', group: 'usage', label: '记录租户用量' },
{ key: 'platform:audit:read', group: 'audit', label: '查看平台审计' },
{ key: 'platform:audit:export', group: 'audit', label: '导出平台审计' },
{ key: 'platform:audit:alert', group: 'audit', label: '处理平台审计告警' },
{ key: 'platform:audit:notification', group: 'audit', label: '维护审计告警通知' },
{ key: 'platform:question_bank:read', group: 'question_bank', label: '查看公共题库' },
{ key: 'platform:question_bank:grant', group: 'question_bank', label: '授权公共题库' },
] as const;
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
@@ -476,8 +498,38 @@ function grantStatusFrom(value: string) {
return status;
}
export async function platformOverviewRoute(ctx: RequestContext) {
function platformPermissionAllowed(permissions: Record<string, unknown>, permission: string) {
if (permissions['*'] === true) return true;
if (permissions[permission] === true) return true;
const parts = permission.split(':');
for (let i = parts.length - 1; i >= 1; i -= 1) {
if (permissions[`${parts.slice(0, i).join(':')}:*`] === true) return true;
}
return false;
}
export async function platformPermissionsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const session = currentSessionFromContext(ctx);
const permissions = session?.platformPermissions && typeof session.platformPermissions === 'object'
? session.platformPermissions
: { '*': true };
return {
item: {
userId: session?.id || null,
primaryRole: session?.primaryRole || 'platform_admin_key',
permissions,
effective: Object.fromEntries(
PLATFORM_PERMISSION_CATALOG.map(item => [item.key, platformPermissionAllowed(permissions, item.key)]),
),
catalog: PLATFORM_PERMISSION_CATALOG,
},
};
}
export async function platformOverviewRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:overview:read');
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
queryOne<{
@@ -554,7 +606,7 @@ export async function platformOverviewRoute(ctx: RequestContext) {
}
export async function platformPlansRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:plan:read');
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
const items = await query(
@@ -574,7 +626,7 @@ export async function platformPlansRoute(ctx: RequestContext) {
}
export async function platformQuestionBanksRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:question_bank:read');
const q = listQuery(ctx, 'q');
const regionId = listQuery(ctx, 'regionId');
@@ -617,7 +669,7 @@ export async function platformQuestionBanksRoute(ctx: RequestContext) {
}
export async function questionBankGrantsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:question_bank:read');
const questionBankId = listQuery(ctx, 'questionBankId');
const status = listQuery(ctx, 'status');
@@ -649,7 +701,7 @@ export async function questionBankGrantsRoute(ctx: RequestContext) {
}
export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:question_bank:grant');
const body = await readJsonBody(ctx);
const questionBankId = requiredString(body, 'sourceQuestionBankId');
@@ -777,7 +829,7 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
}
export async function tenantsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:tenant:read');
const status = listQuery(ctx, 'status');
const billingStatus = listQuery(ctx, 'billingStatus');
@@ -825,7 +877,7 @@ export async function tenantsRoute(ctx: RequestContext) {
}
export async function tenantDetailRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:tenant:read');
const tenantId = ctx.url.searchParams.get('tenantId') || '';
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
@@ -907,7 +959,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
}
export async function platformAuditLogsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:read');
const auditQuery = auditLogQueryParams(ctx, 100, 500);
const items = await loadPlatformAuditLogs(auditQuery);
@@ -916,7 +968,7 @@ export async function platformAuditLogsRoute(ctx: RequestContext) {
}
export async function platformAuditLogsExportRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:export');
const format = auditExportFormat(listQuery(ctx, 'format'));
const auditQuery = auditLogQueryParams(ctx, 1000, 5000);
@@ -986,7 +1038,7 @@ export async function platformAuditLogsExportRoute(ctx: RequestContext) {
}
export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:alert');
const enabled = listQuery(ctx, 'enabled');
if (enabled && !['true', 'false'].includes(enabled)) {
@@ -1010,7 +1062,7 @@ export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
}
export async function platformAuditAlertsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:alert');
const tenantId = listQuery(ctx, 'tenantId');
const status = listQuery(ctx, 'status');
@@ -1077,7 +1129,7 @@ export async function platformAuditAlertsRoute(ctx: RequestContext) {
}
export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:alert');
const body = await readJsonBody(ctx);
const alertId = requiredString(body, 'alertId');
@@ -1127,7 +1179,7 @@ export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
}
export async function platformAuditNotificationChannelsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:notification');
const enabled = listQuery(ctx, 'enabled');
const provider = listQuery(ctx, 'provider');
@@ -1162,7 +1214,7 @@ export async function platformAuditNotificationChannelsRoute(ctx: RequestContext
}
export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:notification');
const body = await readJsonBody(ctx);
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
@@ -1270,7 +1322,7 @@ export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestCo
}
export async function platformAuditNotificationEventsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:audit:notification');
const channelId = listQuery(ctx, 'channelId');
const alertId = listQuery(ctx, 'alertId');
@@ -1329,7 +1381,7 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext)
}
export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:notification');
const enabled = listQuery(ctx, 'enabled');
const provider = listQuery(ctx, 'provider');
@@ -1362,7 +1414,7 @@ export async function platformDunningNotificationChannelsRoute(ctx: RequestConte
}
export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:notification');
const body = await readJsonBody(ctx);
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
@@ -1471,7 +1523,7 @@ export async function upsertPlatformDunningNotificationChannelRoute(ctx: Request
}
export async function platformDunningNotificationEventsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:notification');
const channelId = listQuery(ctx, 'channelId');
const reminderId = listQuery(ctx, 'reminderId');
@@ -1540,7 +1592,7 @@ export async function platformDunningNotificationEventsRoute(ctx: RequestContext
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:tenant:write');
const body = await readJsonBody(ctx);
const slug = normalizeSlug(requiredString(body, 'slug'));
@@ -1679,7 +1731,7 @@ export async function createTenantRoute(ctx: RequestContext) {
}
export async function updateTenantStatusRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:tenant:status');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -1715,7 +1767,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
}
export async function upsertBillingProfileRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:tenant:billing_profile');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -1781,7 +1833,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
}
export async function createSubscriptionRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:write');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -1844,7 +1896,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) {
}
export async function tenantInvoicesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:read');
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const status = listQuery(ctx, 'status');
@@ -1966,7 +2018,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
}
export async function createInvoiceRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:write');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -1992,7 +2044,7 @@ export async function createInvoiceRoute(ctx: RequestContext) {
}
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:payment');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -2088,7 +2140,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
}
export async function processOverdueInvoicesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:dunning');
const body = await readJsonBody(ctx);
const dryRun = booleanFrom(body.dryRun, false);
@@ -2124,7 +2176,7 @@ export async function processOverdueInvoicesRoute(ctx: RequestContext) {
}
export async function invoiceRemindersRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:dunning');
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const invoiceId = ctx.url.searchParams.get('invoiceId') || '';
@@ -2160,7 +2212,7 @@ export async function invoiceRemindersRoute(ctx: RequestContext) {
}
export async function recordUsageRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:usage:write');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -2187,7 +2239,7 @@ export async function recordUsageRoute(ctx: RequestContext) {
}
export async function tenantUsageRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:usage:read');
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const limit = intParam(ctx, 'limit', 100, 500);
@@ -2274,7 +2326,7 @@ async function subscriptionInvoiceCandidateQuery(params: {
}
export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:read');
const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds');
const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds');
@@ -2292,7 +2344,7 @@ export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
}
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:write');
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -2382,7 +2434,7 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte
}
export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx, 'platform:billing:write');
const body = await readJsonBody(ctx);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds');

View File

@@ -24,6 +24,20 @@ export interface PlatformOverview {
};
}
export interface PlatformPermissionCatalogItem {
key: string;
group?: string | null;
label?: string | null;
}
export interface PlatformPermissionSummary {
userId?: string | null;
primaryRole?: string | null;
permissions?: Record<string, unknown>;
effective?: Record<string, boolean>;
catalog?: PlatformPermissionCatalogItem[];
}
export interface PlatformSaasPlan {
id: string;
code: string;
@@ -468,6 +482,10 @@ export async function loadPlatformOverview() {
return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null });
}
export async function loadPlatformPermissions() {
return apiRequest<{ item?: PlatformPermissionSummary }>('/api/platform-admin/permissions', { tenantId: null });
}
export async function loadPlatformPlans(includeArchived = false) {
return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', {
query: { includeArchived },