diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 6798408..1b6e203 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -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(); diff --git a/apps/api/src/modules/audit/index.ts b/apps/api/src/modules/audit/index.ts index 571015d..cac1e3c 100644 --- a/apps/api/src/modules/audit/index.ts +++ b/apps/api/src/modules/audit/index.ts @@ -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'); + }); }, }; diff --git a/apps/web/src/app/(app)/admin/audit/page.tsx b/apps/web/src/app/(app)/admin/audit/page.tsx index f7ef5aa..f85e9e2 100644 --- a/apps/web/src/app/(app)/admin/audit/page.tsx +++ b/apps/web/src/app/(app)/admin/audit/page.tsx @@ -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(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('GET', '/admin/audit/settings').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function save(e: FormEvent) { + 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 (
+

Aufbewahrung & Export

+

+ 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. +

+ {err && {err}}{ok && {ok}} +
+ + {w && } + {w && } + Export (CSV) +
+ {s.lastPurge + ?

Letzte Aufräumung: {fmt(s.lastPurge.at)}, {s.lastPurge.count} Einträge gelöscht (Frist damals {s.lastPurge.retentionDays} Tage).

+ :

Noch keine Aufräumung durchgeführt.

} +
); +} + export default function Audit() { const [list, setList] = useState(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null); const load = useCallback(() => api('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]); @@ -12,6 +51,7 @@ export default function Audit() {

Audit-Protokoll

{err && {err}} {chain && (chain.brokenAt === null ? Hash-Kette intakt ({chain.checked} Einträge geprüft). : Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.)} +
setAction(e.target.value)} placeholder="z. B. auth. oder customer." />
{list === null ?

Wird geladen …

: list.length === 0 ? :
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1e2d0c5..395c6b8 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -42,6 +42,19 @@ const checkBackup = async () => { }; 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) 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}`)); diff --git a/migrations/034_audit_retention.sql b/migrations/034_audit_retention.sql new file mode 100644 index 0000000..59c3f70 --- /dev/null +++ b/migrations/034_audit_retention.sql @@ -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 +); diff --git a/packages/platform/src/audit.ts b/packages/platform/src/audit.ts index aa98f86..42a213c 100644 --- a/packages/platform/src/audit.ts +++ b/packages/platform/src/audit.ts @@ -65,10 +65,16 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise } } -/** 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 = (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'); 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) { // Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein. const body: Record = { @@ -83,3 +89,42 @@ export async function verifyAuditChain(q: (sql: string) => Promise = (sql } 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 { + 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(); } +}
ZeitAktionAkteurObjektErgebnis