Zum Hauptinhalt springen

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:

  1. lib/prisma.ts nutzt aktuell keinen Neon-Adapter (nur datasources.db.url), es gibt keinerlei Extension-Vorlage im Repo.
  2. Der Code nutzt prisma.$transaction sowohl 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​

DateiInhalt
allowlist.tsAUDITED_MODELS — die protokollierten Modelle, isAuditedModel()
redaction.tsbuildDiff(before, after), isSecretField(), isSensitiveField()
labels.tssanitizeEntityLabel(), resolveDisplayLabel(), deutsche Anzeige-Labels
actor.tsresolveActor(), systemActor()
db.tsauditCreate/auditUpdate/auditDelete/auditSummary, getAuditLogPage, getAuditLogRetentionMonths, getAuditLogActorOptions
retention.tscleanupExpiredAuditLogs()
actions.tsupdateAuditLogSettingsAction
constants.tsDEFAULT_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äfix stripe*.
  • Sensible Felder (bei Änderung nur {changed: true}, keine Werte): allergies, medicalNotes, specialNeeds, dietRestrictions, notes, Präfix emergencyContact*.
  • entity_label: Für Student/Guardian/TrustedPerson wird 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;
});
}
before/after müssen dieselbe Feldmenge haben

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 Einstiegspunkte assignLessonException und removePlannedModification. 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, nur updateTenantGeneralSettings.
  • AbsenceNote hat keine Erstellungsstelle im aktuellen Code (nur reviewAbsenceNote als Update) — daher auch kein auditCreate dafü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, Permission audit: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, nicht audit: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:

  1. Dead-Code-Bereinigung: Product und Project hatten keine Code-Referenzen mehr und wurden komplett aus dem Schema entfernt (eigene Migration remove_product_project), statt sie in die Allowlist aufzunehmen.
  2. Allowlist zuerst erweitern: Subject, Room, LessonTime, JobRole, ClassSubject, GradeLevelSubject wurden vor dem Entfernen ihrer Urheber-Felder auditiert, damit diese Modelle nie ohne Akteur-Nachvollziehbarkeit waren.
  3. Felder entfernen: created_by_id/updated_by_id aus SchoolClass, GradeLevel, GradeLevelSubject, Student, JobRole, Subject, ClassSubject, LessonTime, Room, Timetable, ClassbookEntry, LessonException sowie TeacherAbsence.created_by_id (eigene Migration remove_audit_creator_fields), inkl. der Gegenfelder am User-Modell (created_students, updated_students usw.).

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 keine created_by/updated_by-Felder mehr bekommen.