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
|
|
@ -93,8 +93,15 @@ if (cmd === 'create-superadmin') {
|
||||||
else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; }
|
else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; }
|
||||||
else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2));
|
else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2));
|
||||||
else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; }
|
else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; }
|
||||||
|
} else if (cmd === 'audit-purge') {
|
||||||
|
// Löscht Audit-Einträge jenseits der konfigurierten Aufbewahrungsfrist (auch: audit_settings.retention_days).
|
||||||
|
// Läuft sonst täglich automatisch im Worker; hier v. a. zum manuellen Testen/Nachvollziehen.
|
||||||
|
const { purgeAuditRetention } = await import('../core/audit.js');
|
||||||
|
const days = opt('days') ? Number(opt('days')) : (await one('SELECT retention_days FROM audit_settings WHERE id = 1'))?.retention_days ?? 180;
|
||||||
|
const r = await purgeAuditRetention(days);
|
||||||
|
console.log(JSON.stringify(r, null, 2));
|
||||||
} else {
|
} else {
|
||||||
console.error('Befehle: create-superadmin, reset-password, connector-secrets, import-domains, backup');
|
console.error('Befehle: create-superadmin, reset-password, connector-secrets, import-domains, backup, audit-purge');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { query } from '../../core/db.js';
|
import { one, query, run } from '../../core/db.js';
|
||||||
import { verifyAuditChain } from '../../core/audit.js';
|
import { audit, verifyAuditChain, purgeAuditRetention } from '../../core/audit.js';
|
||||||
import { requirePermission } from '../../core/auth.js';
|
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||||
|
import { badRequest } from '../../core/errors.js';
|
||||||
import type { KcModule } from '../../core/module.js';
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
|
const AUDIT_SQL = 'SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json FROM audit_events';
|
||||||
|
const listQuery = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), from: z.string().date().optional(), to: z.string().date().optional() });
|
||||||
|
// Formel-Injection-Schutz wie im Rechnungs-CSV-Export (apps/api/src/modules/invoices/index.ts) - hier lokal
|
||||||
|
// dupliziert statt importiert, damit Module weiterhin ohne Querverweise aufeinander auskommen.
|
||||||
|
const csvSafe = (s: string) => (/^[=+\-@\t]/.test(s) ? `'${s}` : s);
|
||||||
|
const csvCell = (s: string) => { const v = csvSafe(s); return /[;"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; };
|
||||||
|
|
||||||
export const auditModule: KcModule = {
|
export const auditModule: KcModule = {
|
||||||
name: 'audit',
|
name: 'audit',
|
||||||
register(app: FastifyInstance) {
|
register(app: FastifyInstance) {
|
||||||
|
|
@ -12,8 +20,7 @@ export const auditModule: KcModule = {
|
||||||
requirePermission(req, 'audit.read');
|
requirePermission(req, 'audit.read');
|
||||||
const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);
|
const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json
|
`${AUDIT_SQL} WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
|
||||||
FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
|
|
||||||
ORDER BY id DESC LIMIT ?`,
|
ORDER BY id DESC LIMIT ?`,
|
||||||
[q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit],
|
[q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit],
|
||||||
);
|
);
|
||||||
|
|
@ -23,5 +30,58 @@ export const auditModule: KcModule = {
|
||||||
requirePermission(req, 'audit.read');
|
requirePermission(req, 'audit.read');
|
||||||
return verifyAuditChain();
|
return verifyAuditChain();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Aufbewahrungs-Policy (Default reflektiert den DSGVO-Grundsatz der Speicherbegrenzung, Art. 5 Abs. 1
|
||||||
|
// lit. e - die DSGVO selbst schreibt dafür keine feste Zahl vor; änderbar je nach eigener Löschrichtlinie) ---
|
||||||
|
app.get('/admin/audit/settings', async (req) => {
|
||||||
|
requirePermission(req, 'audit.read');
|
||||||
|
const s = await one('SELECT retention_days, updated_at FROM audit_settings WHERE id = 1');
|
||||||
|
const last = await one('SELECT purged_at, purged_count, purged_through_id, retention_days FROM audit_retention_checkpoints ORDER BY id DESC LIMIT 1');
|
||||||
|
return {
|
||||||
|
retentionDays: s?.retention_days ?? 180, updatedAt: s?.updated_at ?? null,
|
||||||
|
lastPurge: last ? { at: last.purged_at, count: last.purged_count, throughId: last.purged_through_id, retentionDays: last.retention_days } : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.put('/admin/audit/settings', async (req) => {
|
||||||
|
const a = requirePermission(req, 'settings.write');
|
||||||
|
const b = z.object({ retentionDays: z.number().int().min(30).max(3650) }).parse(req.body);
|
||||||
|
await run('UPDATE audit_settings SET retention_days = ? WHERE id = 1', [b.retentionDays]);
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.settings.update', resourceType: 'audit_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { retentionDays: b.retentionDays } });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
// Manuelles Anstoßen (der Worker macht das sonst täglich); v. a. zum sofortigen Nachvollziehen nach einer
|
||||||
|
// Änderung der Frist gedacht, nicht für den Alltag.
|
||||||
|
app.post('/admin/audit/purge', async (req) => {
|
||||||
|
const a = requirePermission(req, 'settings.write');
|
||||||
|
const s = await one('SELECT retention_days FROM audit_settings WHERE id = 1');
|
||||||
|
const res = await purgeAuditRetention(s?.retention_days ?? 180).catch((e: unknown) => { throw badRequest((e as Error).message, 'PURGE_FAILED'); });
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.purge.manual', resourceType: 'audit_events', correlationId: req.correlationId, ip: clientIp(req), after: res });
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** CSV-Export (für Auskunftsersuchen/Nachweis vor einer geplanten Aufräumung). before/after sind bereits
|
||||||
|
* beim Schreiben maskiert (siehe mask() in @kc/platform/audit) - hier keine zusätzliche Maskierung nötig. */
|
||||||
|
app.get('/admin/audit/export.csv', async (req, reply) => {
|
||||||
|
const a = requirePermission(req, 'audit.read');
|
||||||
|
const q = listQuery.parse(req.query);
|
||||||
|
const conds: string[] = ['1=1']; const params: unknown[] = [];
|
||||||
|
if (q.action) { conds.push('action LIKE CONCAT(?, ?)'); params.push(q.action, '%'); }
|
||||||
|
if (q.actor) { conds.push('actor_id = ?'); params.push(q.actor); }
|
||||||
|
if (q.org) { conds.push('org_id = ?'); params.push(q.org); }
|
||||||
|
if (q.from) { conds.push('ts >= ?'); params.push(q.from); }
|
||||||
|
if (q.to) { conds.push('ts <= DATE_ADD(?, INTERVAL 1 DAY)'); params.push(q.to); }
|
||||||
|
const rows = await query(`${AUDIT_SQL} WHERE ${conds.join(' AND ')} ORDER BY id LIMIT 50000`, params);
|
||||||
|
const header = ['ID', 'Zeit', 'Akteur-Typ', 'Akteur-ID', 'Organisation-ID', 'Aktion', 'Objekt-Typ', 'Objekt-ID', 'Ergebnis', 'Fehlerklasse', 'Korrelations-ID', 'IP', 'Vorher', 'Nachher'];
|
||||||
|
const lines = [header.join(';')];
|
||||||
|
for (const r of rows) {
|
||||||
|
lines.push([
|
||||||
|
String(r.id), (r.ts as Date).toISOString(), r.actor_type, r.actor_id ?? '', r.org_id ?? '', csvCell(r.action), r.resource_type ?? '', r.resource_id ?? '',
|
||||||
|
r.result, r.error_class ?? '', r.correlation_id ?? '', r.ip ?? '', csvCell(r.before_json ? JSON.stringify(r.before_json) : ''), csvCell(r.after_json ? JSON.stringify(r.after_json) : ''),
|
||||||
|
].join(';'));
|
||||||
|
}
|
||||||
|
await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.export_csv', resourceType: 'audit_events', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } });
|
||||||
|
reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', `attachment; filename="audit-export-${q.from ?? 'alle'}_${q.to ?? 'alle'}.csv"`);
|
||||||
|
return reply.send('' + lines.join('\r\n') + '\r\n');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,48 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
import { api, errMsg } from '@/lib/api';
|
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 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() {
|
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 [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]);
|
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>
|
<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>}
|
{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>)}
|
{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>
|
<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" /> :
|
{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>
|
<div className="tablewrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Akteur</th><th>Objekt</th><th>Ergebnis</th></tr></thead><tbody>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ const checkBackup = async () => {
|
||||||
};
|
};
|
||||||
setInterval(() => void checkBackup(), 3600_000);
|
setInterval(() => void checkBackup(), 3600_000);
|
||||||
|
|
||||||
|
// Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default
|
||||||
|
// spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch,
|
||||||
|
// mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig.
|
||||||
|
const purgeAudit = async () => {
|
||||||
|
try {
|
||||||
|
const [row] = (await pool.query('SELECT retention_days FROM audit_settings WHERE id = 1')) as any;
|
||||||
|
const { purgeAuditRetention } = await import('@kc/platform/audit');
|
||||||
|
const r = await purgeAuditRetention(row[0]?.retention_days ?? 180);
|
||||||
|
if (r.purged) log(`Audit-Aufbewahrung: ${r.purged} Einträge gelöscht (älter als ${r.retentionDays} Tage)`);
|
||||||
|
} catch (e) { log(`Audit-Aufbewahrung fehlgeschlagen: ${(e as Error).message}`); }
|
||||||
|
};
|
||||||
|
setInterval(() => void purgeAudit(), 24 * 3600_000); void purgeAudit();
|
||||||
|
|
||||||
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
|
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
|
||||||
setInterval(() => void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`)), 120_000);
|
setInterval(() => void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`)), 120_000);
|
||||||
void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`));
|
void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`));
|
||||||
|
|
|
||||||
23
migrations/034_audit_retention.sql
Normal file
23
migrations/034_audit_retention.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Zweiter Teil von #34: Aufbewahrungsfrist als eigene, änderbare Policy (kein fester Gesetzeswert - die DSGVO
|
||||||
|
-- selbst schreibt für sowas keine feste Zahl vor, sondern nur den Grundsatz der Speicherbegrenzung, Art. 5 Abs. 1
|
||||||
|
-- lit. e). Voreinstellung 180 Tage als verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten;
|
||||||
|
-- vom Nutzer je nach eigener Lösch-/Aufbewahrungsrichtlinie anzupassen.
|
||||||
|
CREATE TABLE audit_settings (
|
||||||
|
id TINYINT PRIMARY KEY DEFAULT 1,
|
||||||
|
retention_days INT NOT NULL DEFAULT 180,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||||
|
);
|
||||||
|
INSERT INTO audit_settings (id) VALUES (1);
|
||||||
|
|
||||||
|
-- Löschen einzelner Zeilen bricht die Hash-Kette (jede Zeile hasht die vorherige mit); ein Checkpoint macht das
|
||||||
|
-- rückwirkende Löschen alter Zeilen trotzdem nachvollziehbar überprüfbar: verifyAuditChain beginnt danach nicht
|
||||||
|
-- mehr bei der Genesis-Null, sondern beim Hash der zuletzt gelöschten Zeile, sofern die neue erste Zeile genau
|
||||||
|
-- darauf verweist (sonst würde eine echte Manipulation als "nur aufgeräumt" durchgehen).
|
||||||
|
CREATE TABLE audit_retention_checkpoints (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
purged_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
purged_count INT NOT NULL,
|
||||||
|
purged_through_id BIGINT NOT NULL,
|
||||||
|
checkpoint_hash CHAR(64) NOT NULL,
|
||||||
|
retention_days INT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -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 }> {
|
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');
|
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);
|
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) {
|
for (const r of rows) {
|
||||||
// Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein.
|
// Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein.
|
||||||
const body: Record<string, unknown> = {
|
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 };
|
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(); }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue