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:
parent
5ec8a526b3
commit
3240e6df79
6 changed files with 197 additions and 9 deletions
|
|
@ -1,9 +1,48 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Empty, fmt } from '@/components/ui';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, fmt } from '@/components/ui';
|
||||
|
||||
interface Ev { id: number; ts: string; actorType: string; actorId: string | null; action: string; resourceType: string | null; resourceId: string | null; result: string; correlationId: string | null; ip: string | null }
|
||||
interface Settings { retentionDays: number; updatedAt: string | null; lastPurge: { at: string; count: number; throughId: number; retentionDays: number } | null }
|
||||
|
||||
function Retention() {
|
||||
const { can } = useSession(); const w = can('settings.write');
|
||||
const [s, setS] = useState<Settings | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
|
||||
const load = useCallback(() => api<Settings>('GET', '/admin/audit/settings').then(setS).catch((e) => setErr(errMsg(e))), []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function save(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget);
|
||||
try { await api('PUT', '/admin/audit/settings', { retentionDays: Number(f.get('retentionDays')) }); setOk('Gespeichert.'); void load(); }
|
||||
catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
async function purgeNow() {
|
||||
setErr(''); setOk(''); setBusy(true);
|
||||
try { const r = await api<{ purged: number }>('POST', '/admin/audit/purge'); setOk(r.purged ? `${r.purged} Einträge gelöscht.` : 'Nichts zu löschen – keine Einträge jenseits der Frist.'); void load(); }
|
||||
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||
}
|
||||
if (!s) return null;
|
||||
return (<div className="card">
|
||||
<h2>Aufbewahrung & Export</h2>
|
||||
<p className="muted small">
|
||||
Die DSGVO selbst schreibt keine feste Zahl vor, nur den Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e) – Einträge dürfen nicht länger als nötig aufbewahrt werden.
|
||||
Die Voreinstellung von 180 Tagen ist eine verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten, keine gesetzliche Vorgabe. Bitte an die eigene Lösch-/Aufbewahrungsrichtlinie anpassen.
|
||||
</p>
|
||||
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
|
||||
<form onSubmit={save} className="row" style={{ alignItems: 'flex-end' }}>
|
||||
<Field id="retentionDays" label="Aufbewahrung (Tage)"><input id="retentionDays" name="retentionDays" type="number" min={30} max={3650} defaultValue={s.retentionDays} disabled={!w} /></Field>
|
||||
{w && <button className="btn primary" type="submit">Speichern</button>}
|
||||
{w && <button className="btn" type="button" onClick={purgeNow} disabled={busy}>{busy ? '…' : 'Jetzt aufräumen'}</button>}
|
||||
<a className="btn" href="/api/admin/audit/export.csv">Export (CSV)</a>
|
||||
</form>
|
||||
{s.lastPurge
|
||||
? <p className="muted small">Letzte Aufräumung: {fmt(s.lastPurge.at)}, {s.lastPurge.count} Einträge gelöscht (Frist damals {s.lastPurge.retentionDays} Tage).</p>
|
||||
: <p className="muted small">Noch keine Aufräumung durchgeführt.</p>}
|
||||
</div>);
|
||||
}
|
||||
|
||||
export default function Audit() {
|
||||
const [list, setList] = useState<Ev[] | null>(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null);
|
||||
const load = useCallback(() => api<Ev[]>('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]);
|
||||
|
|
@ -12,6 +51,7 @@ export default function Audit() {
|
|||
<div className="row between"><h1>Audit-Protokoll</h1><button className="btn" onClick={() => api<{ checked: number; brokenAt: number | null }>('GET', '/admin/audit/verify').then(setChain).catch((e) => setErr(errMsg(e)))}>Integrität prüfen</button></div>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{chain && (chain.brokenAt === null ? <Alert kind="ok">Hash-Kette intakt ({chain.checked} Einträge geprüft).</Alert> : <Alert kind="err">Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.</Alert>)}
|
||||
<Retention />
|
||||
<div className="card"><div className="field"><label htmlFor="a">Aktion beginnt mit</label><input id="a" value={action} onChange={(e) => setAction(e.target.value)} placeholder="z. B. auth. oder customer." /></div>
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Einträge" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Akteur</th><th>Objekt</th><th>Ergebnis</th></tr></thead><tbody>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue