fix(audit): audit_events auf DB-Ebene unveränderlich machen (append-only)

Nightbot-Befund #34 (Teil 1 von 2 – "eigene DB-Rolle für Audit ohne
UPDATE/DELETE"): eine echte separate DB-Rolle nur für Audit-Schreibzugriffe
ist mit dem bestehenden Muster (audit() schreibt oft in derselben
Transaktion wie die Fachaktion, die sie protokolliert, über dieselbe
Connection) nicht sauber vereinbar, ohne diese Atomarität zu verlieren
oder alle ~50 Tabellen einzeln neu zu berechtigen (MySQL/MariaDB kann
eine datenbankweite Berechtigung nicht durch eine engere Tabellen-
Berechtigung "überschreiben" – Rechte sind additiv, nicht spezifischer
gewinnt).

Stattdessen: BEFORE UPDATE/DELETE-Trigger auf audit_events, die beides
unabhängig vom verbindenden DB-Nutzer grundsätzlich verweigern (Migration
033). INSERT/SELECT bleiben uneingeschränkt möglich – genau das, was
audit()/verifyAuditChain() je brauchen. TRUNCATE bleibt technisch möglich
(feuert keine Trigger, betrifft nur die Testdatenbank-Zurücksetzung
zwischen Testdateien), DROP TABLE weiterhin auch – echte Kompromittierung
der DB-Zugangsdaten bleibt außerhalb dieser Verteidigungslinie, aber
versehentliche oder fehlerhafte Anwendungscode-Änderungen sind jetzt
ausgeschlossen.

test/core.test.ts angepasst: die bisherige Manipulationssimulation per
UPDATE ist jetzt selbst Teil des Tests (muss fehlschlagen); die Prüfung
"Hash-Kette erkennt Fälschung" simuliert stattdessen eine eingeschleuste
Fälschung per INSERT (weiterhin erlaubt).

Verifiziert: Testsuite (59/59, neuer Testfall für die Trigger-Ablehnung),
echte UPDATE/DELETE-Versuche gegen die Produktionsdatenbank beide
abgelehnt, Hash-Kette danach weiterhin intakt (192 Einträge), volles
Backup+Wiederherstellungstest gegen die echte Produktionsdatenbank
gefahren (Trigger werden korrekt mitgesichert/wiederhergestellt).

Aufbewahrungsfristen (DSGVO) und Export sind Teil desselben Tickets,
brauchen aber eine fachliche/rechtliche Entscheidung des Nutzers und
bleiben offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-29 11:49:26 +02:00
parent a9893b6d32
commit 5ec8a526b3
2 changed files with 23 additions and 4 deletions

View file

@ -132,13 +132,22 @@ describe('Rechte', () => {
});
describe('Audit', () => {
it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt Manipulation', async () => {
it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt eine eingeschleuste Fälschung', async () => {
expect((await verifyAuditChain()).brokenAt).toBeNull();
const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events'));
expect(dump).not.toMatch(/passwort-owner|correct-horse/);
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3');
await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]);
expect((await verifyAuditChain()).brokenAt).toBe(first!.id);
// audit_events ist auf DB-Ebene unveränderlich (Migration 033); eine Manipulation ist daher nur noch als
// eingeschleuste Fälschung denkbar (z. B. Wiedereinspielen einer alten Sicherung neben der echten Kette),
// nicht mehr als nachträgliches UPDATE einer bestehenden Zeile.
await run("INSERT INTO audit_events (actor_type, action, result, prev_hash, hash, hash_version) VALUES ('system','gefaelscht','success', REPEAT('0',64), REPEAT('f',64), 2)");
const fake = await one("SELECT id FROM audit_events WHERE action = 'gefaelscht'");
expect((await verifyAuditChain()).brokenAt).toBe(fake!.id);
});
it('verweigert UPDATE und DELETE auf audit_events auf DB-Ebene (append-only)', async () => {
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1');
await expect(run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id])).rejects.toThrow(/unveraenderlich/);
await expect(run('DELETE FROM audit_events WHERE id = ?', [first!.id])).rejects.toThrow(/unveraenderlich/);
});
});