import 'server-only' import { headers } from 'next/headers' import type { AuditAktion, Prisma } from '@prisma/client' import { db } from '@/lib/db' import { verschluesseln } from '@/lib/crypto/feld' import { ipHashen } from '@/lib/crypto/hash' /** * Audit-Log. * * Jede schreibende Aktion und jeder Dokumentzugriff wird protokolliert: * wer, wann, was, alter Wert -> neuer Wert. Der Eintrag ist unveraenderlich * (Datenbank-Trigger, siehe prisma/sql/audit-schutz.sql). * * Das ist nicht nur Datenschutz-Kuer. Als ehrenamtlicher Betreuer muss ich * gegenueber dem Betreuungsgericht und moeglichen Miterben belegen koennen, * was ich wann getan habe. Das Log ist dieser Beleg. * * Die geaenderten Werte werden verschluesselt abgelegt - dort stehen sonst * Diagnosen und Kontostaende im Klartext. */ export type Aenderung = Record type ProtokollEingabe = { aktion: AuditAktion entitaet: string entitaetId?: string | null bezeichnung?: string | null betreuteId?: string | null userId?: string | null userEmail?: string | null userName?: string | null aenderung?: Aenderung | null /** Optional: laeuft die Aktion in einer Transaktion mit? */ tx?: Prisma.TransactionClient } /** Felder, die nie ins Log gehoeren - auch nicht verschluesselt. */ const NIEMALS_PROTOKOLLIEREN = new Set([ 'passwortHash', 'passwort', 'passwortWiederholung', 'totpSecretVerschluesselt', 'wiederherstellungscodes', ]) /** * Liest IP und User-Agent aus der laufenden Anfrage. Ausserhalb eines * Request-Kontexts (z.B. im Seed oder in einem Cron-Job) gibt es beides * nicht - dann bleibt das Feld leer, statt dass die Aktion scheitert. */ async function anfragekontext(): Promise<{ ipHash: string | null; userAgent: string | null }> { try { const kopf = await headers() // Hinter dem Reverse Proxy steht die echte Adresse in X-Forwarded-For. const weitergeleitet = kopf.get('x-forwarded-for')?.split(',')[0]?.trim() const ip = weitergeleitet || kopf.get('x-real-ip') || null return { ipHash: ipHashen(ip), userAgent: kopf.get('user-agent')?.slice(0, 250) ?? null, } } catch { return { ipHash: null, userAgent: null } } } /** * Schreibt einen Eintrag. Wirft bewusst nicht: ein Fehler im Protokoll * darf die eigentliche Fachaktion nicht abbrechen - er wird geloggt und * faellt beim naechsten Blick in die Serverlogs auf. * * Laeuft die Aktion in einer Transaktion (tx uebergeben), gilt das nicht: * dann soll der Protokolleintrag zusammen mit der Aenderung stehen oder * fallen. */ export async function protokollieren(eingabe: ProtokollEingabe): Promise { const { tx, aenderung, ...rest } = eingabe const klient = tx ?? db try { const kontext = await anfragekontext() const gefiltert = aenderung ? aenderungFiltern(aenderung) : null await klient.auditLog.create({ data: { aktion: rest.aktion, entitaet: rest.entitaet, entitaetId: rest.entitaetId ?? null, bezeichnung: rest.bezeichnung ?? null, betreuteId: rest.betreuteId ?? null, userId: rest.userId ?? null, userEmail: rest.userEmail ?? null, userName: rest.userName ?? null, aenderungVerschluesselt: gefiltert && Object.keys(gefiltert).length > 0 ? verschluesseln(JSON.stringify(gefiltert), 'aenderungVerschluesselt') : null, ipHash: kontext.ipHash, userAgent: kontext.userAgent, }, }) } catch (fehler) { if (tx) throw fehler console.error('[audit] Eintrag konnte nicht geschrieben werden:', fehler) } } function aenderungFiltern(aenderung: Aenderung): Aenderung { const ergebnis: Aenderung = {} for (const [feld, werte] of Object.entries(aenderung)) { if (NIEMALS_PROTOKOLLIEREN.has(feld)) { ergebnis[feld] = { alt: '[entfernt]', neu: '[entfernt]' } continue } ergebnis[feld] = werte } return ergebnis } /** * Vergleicht zwei Datensaetze und liefert nur die tatsaechlich geaenderten * Felder. So steht im Log nicht bei jedem Speichern der ganze Datensatz. * * Verschluesselte Felder werden am Suffix erkannt: verglichen wird der * Klartext (den der Aufrufer mitgibt), protokolliert ebenfalls der * Klartext - der Eintrag selbst ist ja verschluesselt. */ export function aenderungErmitteln( vorher: Record, nachher: Record, ): Aenderung { const aenderung: Aenderung = {} for (const feld of Object.keys(nachher)) { if (feld.endsWith('Verschluesselt')) continue // Umschlaege aendern sich immer if (['aktualisiertAm', 'erstelltAm', 'id'].includes(feld)) continue const alt = vorher[feld] const neu = nachher[feld] if (!gleich(alt, neu)) { aenderung[feld] = { alt: normalisieren(alt), neu: normalisieren(neu) } } } return aenderung } function gleich(a: unknown, b: unknown): boolean { if (a === b) return true if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() if (a instanceof Date || b instanceof Date) { return normalisieren(a) === normalisieren(b) } if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((wert, i) => gleich(wert, b[i])) } if (a === null || a === undefined) return b === null || b === undefined return false } /** BigInt und Date sind nicht JSON-faehig - hier in Text wandeln. */ function normalisieren(wert: unknown): unknown { if (wert instanceof Date) return wert.toISOString() if (typeof wert === 'bigint') return wert.toString() return wert ?? null }