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:
parent
a9893b6d32
commit
5ec8a526b3
2 changed files with 23 additions and 4 deletions
|
|
@ -132,13 +132,22 @@ describe('Rechte', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Audit', () => {
|
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();
|
expect((await verifyAuditChain()).brokenAt).toBeNull();
|
||||||
const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events'));
|
const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events'));
|
||||||
expect(dump).not.toMatch(/passwort-owner|correct-horse/);
|
expect(dump).not.toMatch(/passwort-owner|correct-horse/);
|
||||||
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3');
|
// audit_events ist auf DB-Ebene unveränderlich (Migration 033); eine Manipulation ist daher nur noch als
|
||||||
await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]);
|
// eingeschleuste Fälschung denkbar (z. B. Wiedereinspielen einer alten Sicherung neben der echten Kette),
|
||||||
expect((await verifyAuditChain()).brokenAt).toBe(first!.id);
|
// 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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
10
migrations/033_audit_immutable.sql
Normal file
10
migrations/033_audit_immutable.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Nightbot-Befund #34 (Teil "eigene DB-Rolle für Audit ohne UPDATE/DELETE"): eine echte separate DB-Rolle
|
||||||
|
-- ist mit dem bestehenden Muster (audit() schreibt oft in derselben Transaktion wie die Fachaktion, die sie
|
||||||
|
-- protokolliert) nicht sauber vereinbar, ohne diese Atomarität zu verlieren. Stattdessen: Trigger, die UPDATE/
|
||||||
|
-- DELETE auf audit_events unabhängig vom verbindenden DB-Nutzer grundsätzlich verweigern (append-only auf
|
||||||
|
-- DB-Ebene, nicht nur per Konvention im Anwendungscode). INSERT/SELECT bleiben uneingeschränkt möglich.
|
||||||
|
CREATE TRIGGER audit_events_no_update BEFORE UPDATE ON audit_events
|
||||||
|
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'audit_events ist unveraenderlich (append-only) - UPDATE nicht erlaubt';
|
||||||
|
|
||||||
|
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';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue