feat(audit): Aufbewahrungs-Policy mit Checkpoint-Löschung und CSV-Export

Zweiter Teil von #34: Aufbewahrungsfristen und Export waren offen. Die
DSGVO selbst schreibt keine feste Zahl vor (nur den Grundsatz der
Speicherbegrenzung, Art. 5 Abs. 1 lit. e) - daher eine eigene, änderbare
Policy statt eines hart codierten Gesetzeswerts. Voreinstellung 180 Tage
als verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten.

- Migration 034: audit_settings (retention_days, Default 180) und
  audit_retention_checkpoints.
- purgeAuditRetention() (@kc/platform/audit): löscht Einträge jenseits
  der Frist. Da jede Zeile die vorherige mit hasht, würde einfaches
  Löschen die Kette brechen - ein Checkpoint (Hash der zuletzt gelöschten
  Zeile) macht sie trotzdem weiter überprüfbar. Bricht nur ab, wenn die
  Kette vorher schon fehlerhaft ist, oder bei einer Lücke zwischen
  gelöschten und verbleibenden Zeilen (unerwartete Zeitstempel-
  Reihenfolge). audit_events ist per Trigger unveränderlich (Migration
  033) - der DELETE-Trigger wird dafür kurz entfernt und sofort wieder
  angelegt, alles unter derselben Sperre wie audit() (re-entrant über
  dieselbe Verbindung, sonst Deadlock). Die Aufräumung selbst wird als
  eigener Audit-Eintrag protokolliert.
- verifyAuditChain() beginnt nach einer Aufräumung beim Checkpoint-Hash
  statt der Genesis-Null - nur wenn die neue erste Zeile auch wirklich
  genau darauf verweist, sonst bliebe eine echte Manipulation unentdeckt.
- Worker: täglicher automatischer Lauf. CLI: audit-purge zum manuellen
  Anstoßen. API: GET/PUT /admin/audit/settings, POST /admin/audit/purge
  (settings.write), GET /admin/audit/export.csv (audit.read, mit
  demselben Formel-Injection-Schutz wie der Rechnungs-Export). Web:
  Einstellungs-Card unter Audit-Protokoll mit Erklärtext zur DSGVO-
  Rechtsgrundlage, Frist-Eingabe, "Jetzt aufräumen" und Export-Link.

Verifiziert: Testsuite (59/59), echter Lauf mit künstlich vordatierten
(aber korrekt verketteten) Testzeilen gegen die Testdatenbank - Kette vor
und nach der Aufräumung intakt, Checkpoint korrekt genutzt, zweiter Lauf
idempotent. Echte HTTP-Endpunkte (inkl. CSRF) gegen Produktion getestet.
Volles Backup+Wiederherstellungstest danach weiterhin grün (201
Einträge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 12:01:00 +02:00
parent 5ec8a526b3
commit 3240e6df79
6 changed files with 197 additions and 9 deletions

View file

@ -65,10 +65,16 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void>
}
}
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null.
* Beginnt nicht immer bei der Genesis-Null: wurden ältere Einträge per Aufbewahrungsfrist gelöscht
* (purgeAuditRetention), beginnt die Prüfung stattdessen beim Checkpoint-Hash der zuletzt gelöschten Zeile –
* nur wenn die aktuell erste Zeile auch wirklich genau darauf verweist, sonst bliebe eine echte Manipulation
* unentdeckt, die sich als "war nur die Aufräumung" tarnt. */
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, connector, correlation_id, ip, before_json, after_json, prev_hash, hash, hash_version FROM audit_events ORDER BY id');
let prev = '0'.repeat(64);
const [checkpoint] = await q('SELECT checkpoint_hash FROM audit_retention_checkpoints ORDER BY id DESC LIMIT 1').catch(() => [] as any[]);
if (checkpoint && rows[0] && rows[0].prev_hash === checkpoint.checkpoint_hash) prev = checkpoint.checkpoint_hash;
for (const r of rows) {
// Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein.
const body: Record<string, unknown> = {
@ -83,3 +89,42 @@ export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (sql
}
return { checked: rows.length, brokenAt: null };
}
export interface RetentionResult { purged: number; retentionDays: number; purgedThroughId: number | null }
/** Löscht Audit-Einträge, die älter als die konfigurierte Aufbewahrungsfrist sind, und hinterlässt einen
* Checkpoint, damit die Hash-Kette danach weiter überprüfbar bleibt (siehe verifyAuditChain).
* audit_events ist per Trigger unveränderlich (Migration 033) – dafür wird der DELETE-Trigger kurz entfernt und
* sofort wieder angelegt, alles unter derselben Sperre wie audit(), damit kein gleichzeitiger Schreibzugriff
* in das kurze Zeitfenster ohne Trigger fällt. Löscht nur, wenn die Kette vorher nachweislich intakt ist. */
export async function purgeAuditRetention(retentionDays: number): Promise<RetentionResult> {
const c = await pool.getConnection();
try {
const [[lockRow]] = await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_audit_chain']) as any;
if (Number(lockRow?.got) !== 1) throw new Error('Audit-Sperre konnte nicht erlangt werden (Zeitüberschreitung oder Fehler)');
try {
const chain = await verifyAuditChain((sql) => c.query(sql).then(([r]) => r as any[]));
if (chain.brokenAt !== null) throw new Error(`Aufräumung abgebrochen: Hash-Kette ist bereits ab Eintrag ${chain.brokenAt} fehlerhaft`);
const [[cutoffRow]] = await c.query('SELECT MAX(id) AS purgeId FROM audit_events WHERE ts < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL ? DAY)', [retentionDays]) as any;
const purgeId = cutoffRow?.purgeId as number | null;
if (purgeId === null) return { purged: 0, retentionDays, purgedThroughId: null };
// Nichts darf übersprungen werden: die nächste verbleibende Zeile muss lückenlos direkt anschließen.
const [[gapCheck]] = await c.query('SELECT MIN(id) AS nextId FROM audit_events WHERE id > ?', [purgeId]) as any;
if (gapCheck?.nextId != null && Number(gapCheck.nextId) !== purgeId + 1) throw new Error('Aufräumung abgebrochen: Lücke zwischen zu löschenden und verbleibenden Einträgen (unerwartete Zeitstempel-Reihenfolge)');
const [[last]] = await c.query('SELECT hash FROM audit_events WHERE id = ?', [purgeId]) as any;
const [[countRow]] = await c.query('SELECT COUNT(*) AS n FROM audit_events WHERE id <= ?', [purgeId]) as any;
await c.query('DROP TRIGGER audit_events_no_delete');
try {
await c.query('DELETE FROM audit_events WHERE id <= ?', [purgeId]);
await c.query('INSERT INTO audit_retention_checkpoints (purged_count, purged_through_id, checkpoint_hash, retention_days) VALUES (?,?,?,?)', [countRow.n, purgeId, last.hash, retentionDays]);
} finally {
await c.query("CREATE TRIGGER audit_events_no_delete BEFORE DELETE ON audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_events ist unveraenderlich (append-only) - DELETE nicht erlaubt'");
}
// Eigene Verbindung übergeben (nicht own=true lassen): dieselbe Sitzung hält die Sperre schon re-entrant,
// eine frisch aus dem Pool geholte Verbindung würde auf genau diese Sperre warten und nach 10s scheitern.
await audit({ actorType: 'system', action: 'audit.retention_purge', resourceType: 'audit_events', after: { purgedCount: countRow.n, purgedThroughId: purgeId, retentionDays } }, c);
return { purged: Number(countRow.n), retentionDays, purgedThroughId: purgeId };
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
}
} finally { c.release(); }
}