feat: add platform audit export

This commit is contained in:
Codex
2026-06-30 05:41:30 +08:00
parent c74432ce98
commit b9957d18d5
13 changed files with 422 additions and 69 deletions

View File

@@ -7,6 +7,7 @@ import {
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantRoute,
invoiceRemindersRoute,
platformAuditLogsExportRoute,
platformAuditLogsRoute,
platformOverviewRoute,
platformPlansRoute,
@@ -36,6 +37,7 @@ export const platformAdminRoutes: RouteDefinition[] = [
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute],
['GET', '/api/platform-admin/audit-logs/export', platformAuditLogsExportRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { currentSessionFromContext } from '../../core/auth-context.js';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
@@ -43,6 +44,67 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function csvLine(values: unknown[]) {
return values.map(csvEscape).join(',');
}
function redactAuditExportValue(value: unknown, parentKey = '', depth = 0): unknown {
if (value === null || value === undefined) return value;
if (depth > 8) return '[REDACTED_DEPTH_LIMIT]';
if (
/(?:password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|app[_-]?secret|authorization|cookie|session|cert|signature|nonce)$/i
.test(parentKey)
) {
return '[REDACTED]';
}
if (Array.isArray(value)) return value.map(item => redactAuditExportValue(item, parentKey, depth + 1));
if (typeof value === 'object') {
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
output[key] = redactAuditExportValue(item, key, depth + 1);
}
return output;
}
return value;
}
function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8');
return {
contentBase64: buffer.toString('base64'),
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
sizeBytes: buffer.length,
};
}
function platformExportTimestamp() {
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
function optionalDateFilter(ctx: RequestContext, name: string) {
const value = listQuery(ctx, name);
if (!value) return '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new HttpError(400, `${name} must use YYYY-MM-DD format`, 'INVALID_DATE');
}
return value;
}
function auditExportFormat(value: string) {
const format = value || 'csv';
if (!['csv', 'json'].includes(format)) {
throw new HttpError(400, 'format is invalid', 'INVALID_EXPORT_FORMAT');
}
return format;
}
function optionalUuidList(value: unknown, key = 'ids', maxLength = 500) {
const values = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
@@ -97,6 +159,120 @@ function requestIp(ctx: RequestContext) {
return forwarded || ctx.req.socket.remoteAddress || null;
}
function auditLogQueryParams(ctx: RequestContext, defaultLimit = 100, maxLimit = 500) {
const tenantId = listQuery(ctx, 'tenantId');
const action = listQuery(ctx, 'action');
const targetType = listQuery(ctx, 'targetType');
const actorUserId = listQuery(ctx, 'actorUserId');
const q = listQuery(ctx, 'q');
const startDate = optionalDateFilter(ctx, 'startDate');
const endDate = optionalDateFilter(ctx, 'endDate');
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (actorUserId && !UUID_RE.test(actorUserId)) throw new HttpError(400, 'actorUserId is invalid', 'INVALID_UUID');
if (startDate && endDate && startDate > endDate) throw new HttpError(400, 'startDate must be before endDate', 'INVALID_DATE_RANGE');
const limit = intParam(ctx, 'limit', defaultLimit, maxLimit);
const params: unknown[] = [];
const filters: string[] = [];
if (tenantId) {
params.push(tenantId);
filters.push(`al.tenant_id = $${params.length}::uuid`);
}
if (action) {
params.push(`${action}%`);
filters.push(`al.action ilike $${params.length}`);
}
if (targetType) {
params.push(targetType);
filters.push(`al.target_type = $${params.length}`);
}
if (actorUserId) {
params.push(actorUserId);
filters.push(`al.actor_user_id = $${params.length}::uuid`);
}
if (startDate) {
params.push(startDate);
filters.push(`al.created_at >= $${params.length}::date`);
}
if (endDate) {
params.push(endDate);
filters.push(`al.created_at < ($${params.length}::date + interval '1 day')`);
}
if (q) {
params.push(`%${q}%`);
filters.push(`(al.action ilike $${params.length} or al.target_type ilike $${params.length} or al.target_id ilike $${params.length})`);
}
return {
filters,
params,
limit,
summary: { tenantId, action, targetType, actorUserId, q, startDate, endDate },
};
}
async function loadPlatformAuditLogs(input: {
filters: string[];
params: unknown[];
limit: number;
}) {
const params = [...input.params, input.limit];
return query<{
id: string;
tenantId: string | null;
tenantSlug: string | null;
tenantName: string | null;
actorUserId: string | null;
actorUsername: string | null;
actorName: string | null;
actorPhone: string | null;
action: string;
targetType: string | null;
targetId: string | null;
details: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
}>(
`
select al.id, al.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", al.actor_user_id as "actorUserId",
u.username as "actorUsername", u.name as "actorName",
u.phone as "actorPhone", al.action,
al.target_type as "targetType", al.target_id as "targetId",
al.details, al.ip_address as "ipAddress", al.user_agent as "userAgent",
al.created_at as "createdAt"
from public.audit_logs al
left join public.tenants t on t.id = al.tenant_id
left join public.platform_users u on u.id = al.actor_user_id
${input.filters.length ? `where ${input.filters.join(' and ')}` : ''}
order by al.created_at desc
limit $${params.length}
`,
params,
);
}
function platformAuditExportItems(items: Awaited<ReturnType<typeof loadPlatformAuditLogs>>) {
return items.map(item => ({
id: item.id,
tenantId: item.tenantId,
tenantSlug: item.tenantSlug,
tenantName: item.tenantName,
actorUserId: item.actorUserId,
actorUsername: item.actorUsername,
actorName: item.actorName,
action: item.action,
targetType: item.targetType,
targetId: item.targetId,
details: redactAuditExportValue(item.details),
ipAddress: item.ipAddress,
userAgent: item.userAgent,
createdAt: item.createdAt,
}));
}
async function recordPlatformAudit(
client: pg.PoolClient,
ctx: RequestContext,
@@ -577,60 +753,82 @@ export async function tenantDetailRoute(ctx: RequestContext) {
export async function platformAuditLogsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = listQuery(ctx, 'tenantId');
const action = listQuery(ctx, 'action');
const targetType = listQuery(ctx, 'targetType');
const actorUserId = listQuery(ctx, 'actorUserId');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [];
const filters: string[] = [];
if (tenantId) {
params.push(tenantId);
filters.push(`al.tenant_id = $${params.length}::uuid`);
}
if (action) {
params.push(`${action}%`);
filters.push(`al.action ilike $${params.length}`);
}
if (targetType) {
params.push(targetType);
filters.push(`al.target_type = $${params.length}`);
}
if (actorUserId) {
params.push(actorUserId);
filters.push(`al.actor_user_id = $${params.length}::uuid`);
}
if (q) {
params.push(`%${q}%`);
filters.push(`(al.action ilike $${params.length} or al.target_type ilike $${params.length} or al.target_id ilike $${params.length})`);
}
params.push(limit);
const items = await query(
`
select al.id, al.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", al.actor_user_id as "actorUserId",
u.username as "actorUsername", u.name as "actorName",
u.phone as "actorPhone", al.action,
al.target_type as "targetType", al.target_id as "targetId",
al.details, al.ip_address as "ipAddress", al.user_agent as "userAgent",
al.created_at as "createdAt"
from public.audit_logs al
left join public.tenants t on t.id = al.tenant_id
left join public.platform_users u on u.id = al.actor_user_id
${filters.length ? `where ${filters.join(' and ')}` : ''}
order by al.created_at desc
limit $${params.length}
`,
params,
);
const auditQuery = auditLogQueryParams(ctx, 100, 500);
const items = await loadPlatformAuditLogs(auditQuery);
return { items };
}
export async function platformAuditLogsExportRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const format = auditExportFormat(listQuery(ctx, 'format'));
const auditQuery = auditLogQueryParams(ctx, 1000, 5000);
const items = await loadPlatformAuditLogs(auditQuery);
const exportItems = platformAuditExportItems(items);
const exportedAt = new Date().toISOString();
const filename = `platform-audit-${platformExportTimestamp()}.${format}`;
const content = format === 'json'
? JSON.stringify({ exportedAt, filters: auditQuery.summary, items: exportItems }, null, 2)
: [
csvLine([
'id',
'tenantId',
'tenantSlug',
'tenantName',
'actorUserId',
'actorUsername',
'actorName',
'action',
'targetType',
'targetId',
'details',
'ipAddress',
'userAgent',
'createdAt',
]),
...exportItems.map(item => csvLine([
item.id,
item.tenantId,
item.tenantSlug,
item.tenantName,
item.actorUserId,
item.actorUsername,
item.actorName,
item.action,
item.targetType,
item.targetId,
item.details,
item.ipAddress,
item.userAgent,
item.createdAt,
])),
].join('\n');
const encoded = contentBase64AndHash(content);
await transaction(async client => {
await recordPlatformAudit(client, ctx, 'platform.audit.exported', 'audit_logs', null, {
format,
filename,
rowCount: items.length,
sha256: encoded.sha256,
filters: auditQuery.summary,
});
});
return {
item: {
filename,
format,
mimeType: format === 'json' ? 'application/json' : 'text/csv',
rowCount: items.length,
exportedAt,
filters: auditQuery.summary,
...encoded,
},
};
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);