feat: add platform audit alert notifications

This commit is contained in:
Codex
2026-06-30 06:46:35 +08:00
parent 5bb0512ba7
commit 7ab702f471
24 changed files with 1731 additions and 30 deletions

View File

@@ -9,6 +9,8 @@ import {
invoiceRemindersRoute,
platformAuditAlertRulesRoute,
platformAuditAlertsRoute,
platformAuditNotificationChannelsRoute,
platformAuditNotificationEventsRoute,
platformAuditLogsExportRoute,
platformAuditLogsRoute,
platformOverviewRoute,
@@ -24,6 +26,7 @@ import {
tenantUsageRoute,
updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute,
upsertPlatformAuditNotificationChannelRoute,
upsertQuestionBankGrantRoute,
upsertBillingProfileRoute,
} from './routes.js';
@@ -44,6 +47,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/audit-alert-rules', platformAuditAlertRulesRoute],
['GET', '/api/platform-admin/audit-alerts', platformAuditAlertsRoute],
['POST', '/api/platform-admin/audit-alerts/status', updatePlatformAuditAlertStatusRoute],
['GET', '/api/platform-admin/audit-notification-channels', platformAuditNotificationChannelsRoute],
['PUT', '/api/platform-admin/audit-notification-channels', upsertPlatformAuditNotificationChannelRoute],
['GET', '/api/platform-admin/audit-notification-events', platformAuditNotificationEventsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -41,9 +41,20 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
return optionalStringArray(body, key).filter(Boolean);
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function truncate(value: unknown, max = 1900) {
return String(value ?? '').slice(0, max);
}
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']);
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'feishu', 'wecom']);
const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
@@ -119,6 +130,98 @@ function auditAlertStatusFrom(value: string) {
return status;
}
function platformAuditNotificationProviderFrom(value: string) {
const provider = value || 'generic';
if (!PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
return provider;
}
function platformAuditSeverityFrom(value: string, fallback = 'medium') {
const severity = value || fallback;
if (!PLATFORM_AUDIT_SEVERITIES.has(severity)) {
throw new HttpError(400, 'severity is invalid', 'INVALID_ALERT_SEVERITY');
}
return severity;
}
function platformAuditNotificationStatusFilter(value: unknown) {
const statuses = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: ['open'];
if (statuses.length === 0 || statuses.length > 4) {
throw new HttpError(400, 'statusFilter is invalid', 'INVALID_STATUS_FILTER');
}
for (const status of statuses) {
if (!PLATFORM_AUDIT_ALERT_STATUSES.has(status)) {
throw new HttpError(400, 'statusFilter contains invalid status', 'INVALID_STATUS_FILTER');
}
}
return [...new Set(statuses)];
}
function platformAuditNotificationChannelCode(value: string) {
const code = normalizeSlug(value).replace(/-/g, '_');
if (!/^[a-z0-9_]{3,64}$/.test(code)) {
throw new HttpError(400, 'channelCode is invalid', 'INVALID_CHANNEL_CODE');
}
return code;
}
function platformSecretRef(scope: string, key: string) {
return `app_private.platform_secrets:${scope}:${key}`;
}
function parsePlatformSecretRef(ref: string | null) {
if (!ref) return null;
const parts = ref.split(':');
if (parts.length !== 3 || parts[0] !== 'app_private.platform_secrets') return null;
return { scope: parts[1], key: parts[2] };
}
function safeWebhookInfo(rawUrl: string) {
try {
const url = new URL(rawUrl);
return {
protocol: url.protocol.replace(':', ''),
host: url.host,
pathname: url.pathname,
};
} catch {
return { protocol: '', host: '', pathname: '' };
}
}
function validatePlatformWebhookUrl(rawUrl: string) {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new HttpError(400, 'webhookUrl is invalid', 'INVALID_WEBHOOK_URL');
}
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '::1'].includes(url.hostname)) {
throw new HttpError(400, 'webhookUrl must use HTTPS outside local development', 'INVALID_WEBHOOK_URL');
}
url.username = '';
url.password = '';
return url.toString();
}
function numberBetween(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), min), max);
}
function channelResponse<T extends Record<string, unknown>>(item: T) {
return {
...item,
webhookUrl: undefined,
webhook: safeWebhookInfo(String(item.webhookUrl || '')),
};
}
interface PlatformAuditAlertRow {
id: string;
tenantId: string | null;
@@ -991,6 +1094,208 @@ export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
};
}
export async function platformAuditNotificationChannelsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const enabled = listQuery(ctx, 'enabled');
const provider = listQuery(ctx, 'provider');
const limit = intParam(ctx, 'limit', 100, 500);
if (enabled && !['true', 'false'].includes(enabled)) {
throw new HttpError(400, 'enabled must be true or false', 'INVALID_BOOLEAN');
}
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
const items = await query<Record<string, unknown>>(
`
select id, channel_code as "channelCode", name, description, enabled,
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
min_severity as "minSeverity", status_filter as "statusFilter",
action_patterns as "actionPatterns", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.platform_audit_notification_channels
where ($1::text = '' or enabled = ($1 = 'true'))
and ($2::text = '' or provider = $2)
order by enabled desc,
case min_severity when 'critical' then 1 when 'high' then 2 when 'medium' then 3 else 4 end,
channel_code asc
limit $3
`,
[enabled, provider, limit],
);
return { items: items.map(channelResponse) };
}
export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
const name = requiredString(body, 'name');
const provider = platformAuditNotificationProviderFrom(optionalString(body, 'provider'));
const webhookUrl = validatePlatformWebhookUrl(requiredString(body, 'webhookUrl'));
const statusFilter = platformAuditNotificationStatusFilter(body.statusFilter);
const actionPatterns = optionalStringArray(body, 'actionPatterns').slice(0, 50);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds', 200);
const minSeverity = platformAuditSeverityFrom(optionalString(body, 'minSeverity'), 'medium');
const timeoutSec = numberBetween(body.timeoutSec, 10, 1, 60);
const description = optionalString(body, 'description') || null;
const metadata = objectValue(body.metadata);
let secretRef = optionalString(body, 'secretRef') || null;
const secret = typeof body.secret === 'string' && body.secret.trim() ? body.secret.trim() : '';
if (secret) secretRef = platformSecretRef('webhook', channelCode);
if (secretRef && !parsePlatformSecretRef(secretRef)) {
throw new HttpError(400, 'secretRef is invalid', 'INVALID_SECRET_REF');
}
const item = await transaction(async client => {
if (secret) {
await client.query(
`
insert into app_private.platform_secrets (
secret_scope, secret_key, secret_value, provider, last_rotated_at
)
values ('webhook', $1, $2, $3, now())
on conflict (secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[channelCode, secret, provider],
);
}
const result = await client.query<Record<string, unknown>>(
`
insert into public.platform_audit_notification_channels (
channel_code, name, description, enabled, provider, webhook_url,
secret_ref, min_severity, status_filter, action_patterns,
tenant_ids, timeout_sec, metadata
)
values (
$1, $2, $3, $4, $5, $6,
$7, $8, $9::text[], $10::text[],
$11::uuid[], $12, $13::jsonb
)
on conflict (channel_code)
do update set name = excluded.name,
description = excluded.description,
enabled = excluded.enabled,
provider = excluded.provider,
webhook_url = excluded.webhook_url,
secret_ref = excluded.secret_ref,
min_severity = excluded.min_severity,
status_filter = excluded.status_filter,
action_patterns = excluded.action_patterns,
tenant_ids = excluded.tenant_ids,
timeout_sec = excluded.timeout_sec,
metadata = excluded.metadata,
updated_at = now()
returning id, channel_code as "channelCode", name, description, enabled,
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
min_severity as "minSeverity", status_filter as "statusFilter",
action_patterns as "actionPatterns", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
channelCode,
name,
description,
body.enabled !== false,
provider,
webhookUrl,
secretRef,
minSeverity,
statusFilter,
actionPatterns,
tenantIds,
timeoutSec,
JSON.stringify(metadata),
],
);
const saved = result.rows[0];
await recordPlatformAudit(client, ctx, 'platform.audit.notification_channel_upserted', 'platform_audit_notification_channel', String(saved.id), {
channelCode,
provider,
enabled: body.enabled !== false,
minSeverity,
statusFilter,
actionPatternCount: actionPatterns.length,
tenantIdCount: tenantIds.length,
secretRefSet: Boolean(secretRef),
secretRotated: Boolean(secret),
webhook: safeWebhookInfo(webhookUrl),
});
return saved;
});
return { item: channelResponse(item) };
}
export async function platformAuditNotificationEventsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const channelId = listQuery(ctx, 'channelId');
const alertId = listQuery(ctx, 'alertId');
const status = listQuery(ctx, 'status');
const provider = listQuery(ctx, 'provider');
const limit = intParam(ctx, 'limit', 100, 500);
if (channelId && !UUID_RE.test(channelId)) throw new HttpError(400, 'channelId is invalid', 'INVALID_UUID');
if (alertId && !UUID_RE.test(alertId)) throw new HttpError(400, 'alertId is invalid', 'INVALID_UUID');
if (status && !PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES.has(status)) {
throw new HttpError(400, 'status is invalid', 'INVALID_NOTIFICATION_EVENT_STATUS');
}
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
const items = await query<Record<string, unknown>>(
`
select e.id, e.channel_id as "channelId", c.channel_code as "channelCode",
c.name as "channelName", e.alert_id as "alertId",
e.audit_log_id as "auditLogId", e.provider, e.status, e.attempts,
e.scheduled_at as "scheduledAt", e.next_attempt_at as "nextAttemptAt",
e.last_attempt_at as "lastAttemptAt", e.sent_at as "sentAt",
e.last_error as "lastError", e.last_http_code as "lastHttpCode",
e.last_response_summary as "lastResponseSummary",
e.request_payload as "requestPayload", e.metadata,
a.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", a.severity as "alertSeverity",
a.status as "alertStatus", a.title as "alertTitle",
a.action as "alertAction", a.target_type as "targetType",
a.target_id as "targetId",
e.created_at as "createdAt", e.updated_at as "updatedAt"
from public.platform_audit_notification_events e
join public.platform_audit_notification_channels c on c.id = e.channel_id
join public.platform_audit_alerts a on a.id = e.alert_id
left join public.tenants t on t.id = a.tenant_id
where ($1::uuid is null or e.channel_id = $1::uuid)
and ($2::uuid is null or e.alert_id = $2::uuid)
and ($3::text = '' or e.status = $3)
and ($4::text = '' or e.provider = $4)
order by
case e.status when 'pending' then 1 when 'retrying' then 2 when 'processing' then 3 when 'failed' then 4 else 5 end,
e.created_at desc
limit $5
`,
[channelId || null, alertId || null, status, provider, limit],
);
return {
items: items.map(item => ({
...item,
requestPayload: redactAuditAlertValue(item.requestPayload),
lastError: truncate(item.lastError),
lastResponseSummary: truncate(item.lastResponseSummary),
})),
};
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);