forked from wangziqi/gongxue-base
feat: add platform audit alert notifications
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user