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 === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 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 {
|
||||
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);
|
||||
}
|
||||
await pool.end();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { query } from '../../core/db.js';
|
||||
import { verifyAuditChain } from '../../core/audit.js';
|
||||
import { requirePermission } from '../../core/auth.js';
|
||||
import { one, query, run } from '../../core/db.js';
|
||||
import { audit, verifyAuditChain, purgeAuditRetention } from '../../core/audit.js';
|
||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||
import { badRequest } from '../../core/errors.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 = {
|
||||
name: 'audit',
|
||||
register(app: FastifyInstance) {
|
||||
|
|
@ -12,8 +20,7 @@ export const auditModule: KcModule = {
|
|||
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 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
|
||||
FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
|
||||
`${AUDIT_SQL} WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
|
||||
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],
|
||||
);
|
||||
|
|
@ -23,5 +30,58 @@ export const auditModule: KcModule = {
|
|||
requirePermission(req, 'audit.read');
|
||||
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');
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue