kundencenter/packages/platform/src/audit.ts
2026-09-27 00:51:32 +02:00

81 lines
4 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { pool, one, query } from './db.js';
const SENSITIVE = /pass|secret|token|hash|code|key|totp/i;
/** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */
export function mask(v: unknown): unknown {
if (Array.isArray(v)) return v.map(mask);
if (v && typeof v === 'object') {
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)]));
}
return v;
}
export interface AuditInput {
actorType: 'user' | 'system' | 'anonymous';
actorId?: string | null;
orgId?: string | null;
action: string;
resourceType?: string;
resourceId?: string;
result?: 'success' | 'denied' | 'failure';
errorClass?: string;
connector?: string;
correlationId?: string;
ip?: string;
before?: unknown;
after?: unknown;
}
const canon = (o: unknown) => JSON.stringify(o);
/** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */
export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void> {
const own = !conn;
const c = conn ?? (await pool.getConnection());
try {
if (own) await c.beginTransaction();
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c);
const prev = last?.hash ?? '0'.repeat(64);
const ts = new Date();
const body = {
ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action,
resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success',
error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null,
before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after),
};
const hash = createHash('sha256').update(prev + canon(body)).digest('hex');
await c.execute(
`INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null,
body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash],
);
if (own) await c.commit();
} catch (err) {
if (own) await c.rollback();
throw err;
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
if (own) c.release();
}
}
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> {
const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id');
let prev = '0'.repeat(64);
for (const r of rows) {
const body = {
ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action,
resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id,
before: r.before_json ?? null, after: r.after_json ?? null,
};
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number };
prev = r.hash as string;
}
return { checked: rows.length, brokenAt: null };
}