feat: add platform audit alerts

This commit is contained in:
Codex
2026-06-30 06:12:33 +08:00
parent b9957d18d5
commit 5bb0512ba7
20 changed files with 1061 additions and 23 deletions

View File

@@ -1232,6 +1232,8 @@ async function testPlatformTenantOperationsAndAudit() {
assert.ok(actions.has('platform.tenant.created'), 'platform audit should include tenant creation');
assert.ok(actions.has('platform.tenant.billing_profile_upserted'), 'platform audit should include billing profile update');
assert.ok(actions.has('platform.tenant.status_updated'), 'platform audit should include status update');
const statusAuditLog = audit.items?.find(item => item.action === 'platform.tenant.status_updated');
assert.ok(statusAuditLog?.id, 'platform tenant status audit should expose audit log id');
const auditExport = await request('/api/platform-admin/audit-logs/export', {
tenantId: false,
@@ -1253,6 +1255,92 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(invalidAuditExportFormat.code, 'INVALID_EXPORT_FORMAT', 'platform audit export should reject invalid formats');
const alertRules = await request('/api/platform-admin/audit-alert-rules', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { enabled: true },
});
const statusAlertRule = alertRules.items?.find(item => item.code === 'platform_tenant_status_changed');
assert.ok(statusAlertRule?.id, 'platform audit alert rules should include default tenant status rule');
assert.ok(
alertRules.items?.some(item => item.code === 'platform_invoice_payment_confirmed'),
'platform audit alert rules should include manual payment confirmation rule',
);
const alertPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
let platformAlertId = '';
try {
const insertedAlert = await alertPool.query(
`
insert into public.platform_audit_alerts (
rule_id, audit_log_id, tenant_id, severity, status,
action, target_type, target_id, title, summary, details,
first_seen_at, last_seen_at
)
values (
$1, $2, $3::uuid, 'high', 'open',
'platform.tenant.status_updated', 'tenant', $4,
'租户状态变更告警', 'integration platform audit alert',
'{"source":"api-integration-test","accessToken":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
now(), now()
)
on conflict (rule_id, audit_log_id) do update
set status = 'open',
updated_at = now()
returning id
`,
[statusAlertRule.id, statusAuditLog.id, tenantId, tenantId],
);
platformAlertId = insertedAlert.rows[0].id;
} finally {
await alertPool.end();
}
const auditAlerts = await request('/api/platform-admin/audit-alerts', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, status: 'open', limit: 20 },
});
assert.ok(
auditAlerts.items?.some(item => item.id === platformAlertId && item.ruleCode === 'platform_tenant_status_changed'),
'platform admin should list platform audit alerts',
);
const listedAlert = auditAlerts.items?.find(item => item.id === platformAlertId);
assert.equal(listedAlert?.details?.accessToken, '[REDACTED]', 'platform audit alert list should redact token-like details');
assert.equal(listedAlert?.details?.nested?.password, '[REDACTED]', 'platform audit alert list should redact nested password-like details');
assert.ok(!JSON.stringify(listedAlert).includes('must-not-leak'), 'platform audit alert list must not leak sensitive details');
const updatedAuditAlert = await request('/api/platform-admin/audit-alerts/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
alertId: platformAlertId,
status: 'resolved',
resolutionNote: 'integration resolved',
},
});
assert.equal(updatedAuditAlert.item?.status, 'resolved', 'platform admin should resolve audit alerts');
assert.equal(updatedAuditAlert.item?.resolutionNote, 'integration resolved', 'audit alert resolution note should persist');
assert.equal(updatedAuditAlert.item?.details?.accessToken, '[REDACTED]', 'audit alert status response should redact token-like details');
assert.ok(!JSON.stringify(updatedAuditAlert).includes('must-not-leak'), 'audit alert status response must not leak sensitive details');
const invalidAuditAlertStatus = await request('/api/platform-admin/audit-alerts/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
alertId: platformAlertId,
status: 'open',
},
expectStatus: 400,
});
assert.equal(invalidAuditAlertStatus.code, 'INVALID_ALERT_STATUS', 'audit alerts should not be reopened through status endpoint');
const candidates = await request('/api/platform-admin/invoices/subscription-candidates', {
tenantId: false,
userId: false,
@@ -1448,6 +1536,25 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(studentAuditExportDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not export platform audit logs');
const studentAuditAlertDenied = await request('/api/platform-admin/audit-alerts', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditAlertDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit alerts');
const studentAuditAlertStatusDenied = await request('/api/platform-admin/audit-alerts/status', {
tenantId: false,
userId: USER_ID,
method: 'POST',
body: {
alertId: platformAlertId,
status: 'acknowledged',
},
expectStatus: 403,
});
assert.equal(studentAuditAlertStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not update platform audit alerts');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,

View File

@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import pg from 'pg';
import { spawn } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const ids = {
tenant: '00000000-0000-0000-0000-00000000aa01',
auditLog: '00000000-0000-0000-0000-00000000aa02',
};
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-audit-alerts'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '20',
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS: '30',
WORKER_PLATFORM_AUDIT_ALERT_ID: 'platform-audit-alert-worker-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => {
try {
assert.equal(code, 0, `worker should exit 0\n${output}`);
assert.match(output, /platform-audit-alerts batch processed=\d+/, 'worker output should include audit alert summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.platform_audit_alerts where tenant_id = $1 or audit_log_id = $2', [ids.tenant, ids.auditLog]);
await pool.query('delete from public.audit_logs where tenant_id = $1 or id = $2', [ids.tenant, ids.auditLog]);
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
}
async function createAuditLog(pool) {
await pool.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, 'platform-audit-alert-worker', '平台审计告警租户', '平台审计告警有限公司', 'active', 'saas', 'active', '{"source":"platform-audit-alert-worker-test"}'::jsonb)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.audit_logs (
id, tenant_id, actor_user_id, action, target_type, target_id,
details, ip_address, user_agent, created_at
)
values (
$1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3,
'{"status":"suspended","reason":"integration alert","accessToken":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
'127.0.0.1', 'platform-audit-alert-worker-test', now()
)
`,
[ids.auditLog, ids.tenant, ids.tenant],
);
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await createAuditLog(pool);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /created=\d+/, 'worker should create alert candidates');
const alerts = await pool.query(
`
select a.id, a.status, a.severity, a.action, a.target_type, a.target_id,
a.details, r.code as rule_code
from public.platform_audit_alerts a
join public.platform_audit_alert_rules r on r.id = a.rule_id
where a.tenant_id = $1 and a.audit_log_id = $2
`,
[ids.tenant, ids.auditLog],
);
assert.equal(alerts.rowCount, 1, 'worker should create one alert for the audit log');
assert.equal(alerts.rows[0].status, 'open', 'worker alert should start open');
assert.equal(alerts.rows[0].severity, 'high', 'tenant status changes should be high severity');
assert.equal(alerts.rows[0].rule_code, 'platform_tenant_status_changed', 'worker should match default tenant status rule');
assert.equal(alerts.rows[0].details?.workerId, 'platform-audit-alert-worker-test', 'alert should record worker id');
assert.equal(alerts.rows[0].details?.auditDetails?.accessToken, '[REDACTED]', 'alert should redact token-like audit details');
assert.equal(alerts.rows[0].details?.auditDetails?.nested?.password, '[REDACTED]', 'alert should redact nested password-like audit details');
assert.ok(!JSON.stringify(alerts.rows[0].details).includes('must-not-leak'), 'alert details must not leak sensitive audit values');
const audit = await pool.query(
`
select action, target_type, target_id, details
from public.audit_logs
where tenant_id = $1 and action = 'platform.audit.alert_created'
order by created_at desc
limit 1
`,
[ids.tenant],
);
assert.equal(audit.rows[0]?.target_type, 'platform_audit_alert', 'worker should audit alert creation');
assert.equal(audit.rows[0]?.details?.ruleCode, 'platform_tenant_status_changed', 'worker audit should include rule code');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /created=0/, 'second worker run should not create duplicate alerts');
const count = await pool.query(
`
select count(*)::integer as count
from public.platform_audit_alerts
where tenant_id = $1 and audit_log_id = $2
`,
[ids.tenant, ids.auditLog],
);
assert.equal(Number(count.rows[0]?.count), 1, 'worker should be idempotent');
console.log('Platform audit alert worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});