Audit-Log
Zentrales, append-only Änderungsprotokoll: wer (Benutzer/Portal/System) hat wann welchen Datensatz einer Allowlist von Modellen angelegt, geändert oder gelöscht, und was hat sich dabei geändert. Setzt docs/TASK-audit-log.md (Phase A) um.
Architekturentscheidung: expliziter Wrapper statt Prisma Client Extension
Die Aufgabenstellung ließ offen, ob eine Prisma Client Extension ($extends) oder ein expliziter Wrapper verwendet wird. Entscheidung: expliziter Wrapper, aus zwei Gründen:
lib/prisma.tsnutzt aktuell keinen Neon-Adapter (nurdatasources.db.url), es gibt keinerlei Extension-Vorlage im Repo.- Der Code nutzt
prisma.$transactionsowohl interaktiv (async (tx) => {...}) als auch als Batch-Array (prisma.$transaction([...])). In der Batch-Array-Form lässt sich kein zusätzlicher Audit-Write in derselben Transaktion einschleusen — eine deklarative Extension könnte das nicht zuverlässig abdecken. Betroffene Stellen (lib/user/db.ts,rollen-und-rechte/actions.ts) wurden im Zuge dieser Aufgabe auf die interaktive Form umgestellt.
Aufrufer rufen die Wrapper-Funktionen aus lib/audit/db.ts explizit innerhalb ihrer eigenen prisma.$transaction(async (tx) => {...})-Blöcke auf — analog zu lib/student-class-history/db.ts.
lib/audit/ Modul
| Datei | Inhalt |
|---|---|
allowlist.ts | AUDITED_MODELS — die protokollierten Modelle, isAuditedModel() |
redaction.ts | buildDiff(before, after), isSecretField(), isSensitiveField() |
labels.ts | sanitizeEntityLabel(), resolveDisplayLabel(), deutsche Anzeige-Labels |
actor.ts | resolveActor(), systemActor() |
db.ts | auditCreate/auditUpdate/auditDelete/auditSummary, getAuditLogPage, getAuditLogRetentionMonths, getAuditLogActorOptions |
retention.ts | cleanupExpiredAuditLogs() |
actions.ts | updateAuditLogSettingsAction |
constants.ts | DEFAULT_AUDIT_LOG_RETENTION_MONTHS |
Allowlist
Student, Guardian, TrustedPerson, StudentClassHistory, SchoolClass, SchoolYear, GradeLevel, Timetable, LessonException, ClassbookEntry, Attendance, AbsenceNote, TeacherAbsence, User, Role, RolePermission, UserCustomRole, Tenant, ClassbookSettings, PortalInvitation, GuardianPortalLink, sowie die Stammdaten Subject, Room, LessonTime, JobRole, ClassSubject und GradeLevelSubject (im Zuge von Phase B ergänzt, siehe unten).
Bewusst nicht protokolliert: Session/Account/VerificationToken/PortalSession (Auth-Infrastruktur), FileAccessLog (eigenes Zugriffsprotokoll), AuditLog selbst, Billing-Modelle.
Massenoperationen bei ClassSubject/GradeLevelSubject: createMany (z. B. beim Anlegen einer Klasse oder beim Übernehmen eines Fachs auf alle Klassen einer Klassenstufe) erzeugt eine Sammelzeile über auditSummary statt einer Zeile pro betroffenem Datensatz. Siehe lib/school-class/db.ts::createSchoolClass, lib/school-year/transition.ts, lib/grade-level-subject/db.ts und lib/grade-level-subject/actions-apply.ts als Referenz.
Ein neues Modell zur Allowlist hinzufügen: in AUDITED_MODELS ergänzen, an den jeweiligen Schreibstellen auditCreate/auditUpdate/auditDelete aufrufen (siehe Muster unten), optional ein Label in AUDIT_ENTITY_TYPE_LABELS (labels.ts) für die Filter-Oberfläche ergänzen.
Schwärzung
- Secret-Felder (nie im Log, auch nicht als „geändert“):
password_hash,passwordHash,token,access_token,refresh_token,id_token, Präfixstripe*. - Sensible Felder (bei Änderung nur
{changed: true}, keine Werte):allergies,medicalNotes,specialNeeds,dietRestrictions,notes, PräfixemergencyContact*. entity_label: FürStudent/Guardian/TrustedPersonwird jeder übergebene Wert beim Schreiben zentral verworfen (sanitizeEntityLabel) — unabhängig davon, was die Aufrufstelle übergibt. Der Anzeigename wird stattdessen zur Lesezeit live aus der DB aufgelöst (resolveDisplayLabel), mit Fallback „gelöschter Schüler“ etc. bei bereits gelöschten Datensätzen.
Muster an einer Schreibstelle
export async function updateFoo(id: string, data: ...) {
const existing = await prisma.foo.findFirst({ where: { id, tenant_id } });
// ...
return prisma.$transaction(async (tx) => {
const updated = await tx.foo.update({ where: { id }, data });
await auditUpdate(tx, {
tenantId,
entityType: 'Foo',
entityId: id,
entityLabel: updated.name,
before: existing,
after: updated
});
return updated;
});
}
Liest before ohne include/select, aber after (z.B. das Ergebnis von update(... include: {...})) mit zusätzlichen Relationen, erzeugt buildDiff einen Phantom-Diff: das Feld fehlt in before, ist aber in after gesetzt — sieht aus wie eine echte Änderung, ist aber nur ein Shape-Unterschied zwischen den beiden Reads. Umgekehrt (zusätzliches Feld nur in before, z.B. _count) genauso. Vor dem audit*-Aufruf die jeweils zusätzlichen Relationen/_count aus dem Objekt entfernen (Destrukturierung), das andere Objekt entsprechend ergänzen oder ebenfalls kürzen. Beim Smoke-Test dieser Aufgabe mehrfach aufgetreten (Student.school_class, SchoolClass.teacher, User.job_role, Timetable.lesson_time/class_subject/teacher/room) und jeweils behoben.
Massenoperationen (auditSummary)
Bei Operationen, die viele Datensätze in einer Transaktion ändern (Schuljahreswechsel, Klassen-weite Anwesenheitserfassung), wird eine Sammelzeile statt einer Zeile pro Datensatz geschrieben: auditSummary(tx, { tenantId, entityType, entityId, action, context }), context enthält die Statistik (z.B. { source: 'createSchoolYearTransition', stats: {...} }). Es gibt keinen separaten withoutAudit()-Unterdrückungsmechanismus: die Low-Level-Funktionen (z.B. assignStudentToClass aus einem zukünftigen Klassenverlauf-Modul) rufen den Wrapper grundsätzlich nicht selbst auf — nur die aufrufende Top-Level-Funktion entscheidet, ob und wie (einzeln oder als Sammelzeile) auditiert wird.
Transaktionspflicht
Der Audit-Insert muss in derselben Transaktion wie die fachliche Änderung liegen, sonst entstehen bei einem Rollback verwaiste oder bei einem Absturz nach dem Write fehlende Einträge. Alle Schreibstellen, die auditiert werden, wurden deshalb (falls noch nicht geschehen) in prisma.$transaction(async (tx) => {...}) umgestellt.
Bekannte, bewusst zurückgestellte Lücken
- Die spezialisierten Varianten in
lib/actions/vertretungsplan/lesson-exception.ts(cancelLesson,overrideLessonRoom,overrideLessonSubjectOrTeacher,moveLesson,publishLessonException(s),applyLessonExceptionToSiblings) sind nicht auditiert — nur die beiden zentralen EinstiegspunkteassignLessonExceptionundremovePlannedModification. Datei hat >1300 Zeilen und 12 Schreibfunktionen, vollständige Abdeckung war außerhalb des Rahmens dieses Durchgangs. Tenant-Erstellung bei Registrierung/Stripe-Webhook (lib/registration/db.ts,app/api/stripe/webhook/route.ts) ist nicht auditiert, nurupdateTenantGeneralSettings.AbsenceNotehat keine Erstellungsstelle im aktuellen Code (nurreviewAbsenceNoteals Update) — daher auch keinauditCreatedafür.
Retention
Pro Mandant konfigurierbare Aufbewahrungsfrist in Monaten (AuditLogSettings.retention_months, Default DEFAULT_AUDIT_LOG_RETENTION_MONTHS = 24 ohne eigene Einstellung). Cron-Route app/api/cron/cleanup-audit-logs/route.ts (täglich, siehe vercel.json) ruft cleanupExpiredAuditLogs() auf, die pro Mandant einzeln löscht (kein globales deleteMany, damit unterschiedliche Fristen respektiert werden).
UI
- Seite
app/(backend)/einstellungen/audit-log/page.tsx, Permissionaudit:read. - Server-gerendert, keine clientseitige
DataTable(die bestehende paginiert nur im Browser und ist für ein potenziell großes Log ungeeignet) — stattdessen URL-Suchparameter-basierte Filter (GET-Formular, kein Client-JS) und Prev/Next-Pagination. components/ui/audit/audit-log-link.tsx— vorgefilterter „Änderungsverlauf“-Link, aktuell eingebunden auf Schüler- und Klassendetailseite.- Retention-Einstellung editierbar über
einstellungen:write(eigenes Recht, nichtaudit:read— Konfiguration vs. reine Logeinsicht).
Phase B: Entfernen der alten Urheber-Felder
Nach Einführung des Audit-Logs (Phase A) wurden die bisherigen created_by_id/updated_by_id-Felder, die nur den letzten Bearbeiter ohne Historie und Diff festhielten, entfernt. Reihenfolge:
- Dead-Code-Bereinigung:
ProductundProjecthatten keine Code-Referenzen mehr und wurden komplett aus dem Schema entfernt (eigene Migrationremove_product_project), statt sie in die Allowlist aufzunehmen. - Allowlist zuerst erweitern:
Subject,Room,LessonTime,JobRole,ClassSubject,GradeLevelSubjectwurden vor dem Entfernen ihrer Urheber-Felder auditiert, damit diese Modelle nie ohne Akteur-Nachvollziehbarkeit waren. - Felder entfernen:
created_by_id/updated_by_idausSchoolClass,GradeLevel,GradeLevelSubject,Student,JobRole,Subject,ClassSubject,LessonTime,Room,Timetable,ClassbookEntry,LessonExceptionsowieTeacherAbsence.created_by_id(eigene Migrationremove_audit_creator_fields), inkl. der Gegenfelder amUser-Modell (created_students,updated_studentsusw.).
Bewusst unverändert (fachliche Bedeutung, kein reiner Audit-Zweck): Tenant.created_by_id, Attendance.recorded_by_id, AbsenceNote.reviewed_by_id, LessonException.published_by_id/substitute_teacher_id, File.uploadedById/archivedById/deletedById, PortalInvitation.createdBy, GuardianPortalLink.createdBy, User.invited_by_id.
Siehe docs/TASK-audit-log.md (Abschnitt „Bereinigung der Urheber-Felder“) für die vollständige Spec.
Verwandte Stellen
lib/audit/— Kernmodul.app/(backend)/einstellungen/audit-log/— UI.app/api/cron/cleanup-audit-logs/route.ts— Retention..claude/rules/prisma-db.md— Hinweis, dass neue Modelle keinecreated_by/updated_by-Felder mehr bekommen.