/** * Zentrale Berechtigungslogik. * * Regel aus CLAUDE.md: Keine Rollenprüfung in Komponenten. Alles, was mit * "darf jemand das?" zu tun hat, steht hier — und nur hier. * * Aufbau: * Recht = ein einzelnes Können, z. B. "geraet.bearbeiten" * Rolle = ein Bündel von Rechten, z. B. GERAETEWART * Sitzung = wer gerade angemeldet ist, mit welchen Rollen, in welcher Wehr * * Die Rechte einer Rolle sind hier als Vorgabe hinterlegt. Beim Seed werden sie * in die Tabellen berechtigung/rolle_berechtigung gespiegelt, damit eine Wehr * sie nachträglich anpassen kann, ohne dass jemand Code ändern muss. */ /** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */ export const ROLLEN = [ "SUPERADMIN", "KOMMANDANT", "GERAETEWART", "ATEMSCHUTZGERAETEWART", "KASSIER", "SCHRIFTFUEHRER", "AUSBILDER", "MITGLIED", "KIOSK", ] as const; export type Rolle = (typeof ROLLEN)[number]; /** Klartext für die Oberfläche. */ export const ROLLEN_BEZEICHNUNG: Record = { SUPERADMIN: "Administrator", KOMMANDANT: "Kommandant", GERAETEWART: "Gerätewart", ATEMSCHUTZGERAETEWART: "Atemschutzgerätewart", KASSIER: "Kassier", SCHRIFTFUEHRER: "Schriftführer", AUSBILDER: "Ausbilder", MITGLIED: "Mitglied", KIOSK: "Terminal im Gerätehaus", }; export const ROLLEN_BESCHREIBUNG: Record = { SUPERADMIN: "Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.", KOMMANDANT: "Führt die Wehr. Sieht und bearbeitet alles innerhalb der eigenen Organisation.", GERAETEWART: "Verantwortet Fahrzeuge, Geräte und deren Prüfungen nach DGUV.", ATEMSCHUTZGERAETEWART: "Verantwortet Atemschutzgeräte, Masken und die Überwachung nach FwDV 7.", KASSIER: "Führt die Kasse: Buchungen, Beiträge, Rechnungen, Auswertungen.", SCHRIFTFUEHRER: "Führt Protokolle, Berichte und die Dokumentation der Wehr.", AUSBILDER: "Plant Übungen und Lehrgänge, führt Teilnahmenachweise.", MITGLIED: "Sieht die eigenen Daten, Termine und Qualifikationen.", KIOSK: "Terminal im Gerätehaus. Darf nur anmelden, quittieren und erfassen — nie verwalten.", }; // ----------------------------------------------------------------------------- // Rechte // ----------------------------------------------------------------------------- /** * Alle Einzelrechte. Schema: . * Neue Rechte hier ergänzen, dann `npm run db:seed` — der Seed gleicht den * Katalog in der Datenbank ab. */ export const RECHTE = { // Organisation und System "organisation.lesen": { bereich: "Organisation", bezeichnung: "Wehrdaten ansehen" }, "organisation.bearbeiten": { bereich: "Organisation", bezeichnung: "Wehrdaten bearbeiten" }, "organisation.verwalten": { bereich: "Organisation", bezeichnung: "Wehren anlegen und löschen" }, "system.einstellungen": { bereich: "System", bezeichnung: "Systemeinstellungen ändern" }, "system.auditlog": { bereich: "System", bezeichnung: "Audit-Log einsehen" }, // Personen "person.lesen": { bereich: "Personen", bezeichnung: "Mitgliederliste ansehen" }, "person.eigene": { bereich: "Personen", bezeichnung: "Eigene Daten ansehen und pflegen" }, "person.bearbeiten": { bereich: "Personen", bezeichnung: "Mitglieder anlegen und bearbeiten" }, "person.loeschen": { bereich: "Personen", bezeichnung: "Mitglieder austragen" }, "person.rollen": { bereich: "Personen", bezeichnung: "Rollen zuweisen" }, "person.ausweis": { bereich: "Personen", bezeichnung: "PIN und RFID-Ausweise verwalten" }, "person.export": { bereich: "Personen", bezeichnung: "Personendaten exportieren (DSGVO)" }, // Fahrzeuge und Geräte "fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" }, "fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" }, // Fahrtenbuch (Phase 4). Eintragen darf jedes Mitglied, das fährt — die // Nachweispflicht trifft die Wehr, nicht den Gerätewart. "fahrtenbuch.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrtenbuch ansehen" }, "fahrtenbuch.fuehren": { bereich: "Fahrzeuge", bezeichnung: "Fahrten eintragen" }, "geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" }, "geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" }, "geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" }, // Prüfungen und Wartung (Phase 2) "pruefung.lesen": { bereich: "Prüfungen", bezeichnung: "Prüfungen und Fristen ansehen" }, "pruefung.durchfuehren": { bereich: "Prüfungen", bezeichnung: "Prüfungen durchführen und dokumentieren" }, "pruefung.planen": { bereich: "Prüfungen", bezeichnung: "Prüftermine und Vorlagen planen" }, // Atemschutz nach FwDV 7 (Phase 5) "atemschutz.lesen": { bereich: "Atemschutz", bezeichnung: "Atemschutzdaten ansehen" }, "atemschutz.bearbeiten": { bereich: "Atemschutz", bezeichnung: "Atemschutzgeräte und Träger verwalten" }, "atemschutz.ueberwachung": { bereich: "Atemschutz", bezeichnung: "Atemschutzüberwachung führen" }, // Einsätze und Übungen (Phase 3) "einsatz.lesen": { bereich: "Einsätze", bezeichnung: "Einsätze ansehen" }, "einsatz.bearbeiten": { bereich: "Einsätze", bezeichnung: "Einsätze erfassen und bearbeiten" }, "einsatz.bericht": { bereich: "Einsätze", bezeichnung: "Einsatzberichte erstellen" }, // Ausbildung und Qualifikationen (Phase 1 und 3) "ausbildung.lesen": { bereich: "Ausbildung", bezeichnung: "Übungen und Lehrgänge ansehen" }, "ausbildung.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Übungen planen und Teilnahme erfassen" }, "qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" }, "qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" }, // Kasse (Phase 9) "kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" }, "kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" }, "kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" }, // Dateien "datei.lesen": { bereich: "Dateien", bezeichnung: "Dateien herunterladen" }, "datei.hochladen": { bereich: "Dateien", bezeichnung: "Dateien hochladen" }, "datei.loeschen": { bereich: "Dateien", bezeichnung: "Dateien löschen" }, // Terminal im Gerätehaus (Phase 4) "kiosk.anmelden": { bereich: "Terminal", bezeichnung: "Am Terminal anmelden" }, "kiosk.erfassen": { bereich: "Terminal", bezeichnung: "Am Terminal Erfassungen vornehmen" }, // Termine und Benachrichtigungen (Phase 7) "termin.lesen": { bereich: "Termine", bezeichnung: "Terminkalender ansehen" }, "termin.bearbeiten": { bereich: "Termine", bezeichnung: "Termine anlegen, ändern und absagen" }, "termin.anwesenheit": { bereich: "Termine", bezeichnung: "Anwesenheit abhaken" }, "benachrichtigung.eigene": { bereich: "Benachrichtigungen", bezeichnung: "Eigene Benachrichtigungswege einrichten", }, "benachrichtigung.vorlagen": { bereich: "Benachrichtigungen", bezeichnung: "Textvorlagen der Wehr bearbeiten", }, // Wissen und lokale KI (Phase 8) "wiki.lesen": { bereich: "Wissen", bezeichnung: "Dienstvorschriften und Unterlagen lesen" }, "wiki.bearbeiten": { bereich: "Wissen", bezeichnung: "Wiki-Seiten anlegen und ändern" }, /// Diktat, Berichtsentwurf, Frage ans Wiki. Wer schreibt, darf sich helfen /// lassen — entschieden wird trotzdem von Hand. "ki.nutzen": { bereich: "Wissen", bezeichnung: "KI-Unterstützung benutzen" }, "ki.protokoll": { bereich: "Wissen", bezeichnung: "Alle KI-Vorgänge einsehen" }, "beleg.erfassen": { bereich: "Kasse", bezeichnung: "Belege fotografieren und erfassen" }, } as const; export type Recht = keyof typeof RECHTE; export const ALLE_RECHTE = Object.keys(RECHTE) as Recht[]; // ----------------------------------------------------------------------------- // Rolle zu Recht // ----------------------------------------------------------------------------- /** Rechte, die jede angemeldete Person hat. */ const GRUNDRECHTE: Recht[] = [ "person.eigene", "organisation.lesen", "person.lesen", "fahrzeug.lesen", "fahrtenbuch.lesen", "fahrtenbuch.fuehren", "geraet.lesen", "pruefung.lesen", "einsatz.lesen", "ausbildung.lesen", "qualifikation.lesen", "datei.lesen", // Den Kalender sieht jeder, und jeder richtet seine eigenen // Benachrichtigungswege selbst ein — das ist keine Sache des Kommandanten. "termin.lesen", "benachrichtigung.eigene", // Dienstvorschriften sind zum Lesen da. Eine Wehr, in der nur die Führung // nachschlagen darf, hat kein Wiki, sondern einen Aktenschrank. "wiki.lesen", ]; /** * Vorgabe je Rolle. SUPERADMIN steht bewusst nicht drin — der bekommt in * hatRecht() pauschal alles, damit ein neu ergänztes Recht nicht versehentlich * am Administrator vorbeigeht. */ export const ROLLEN_RECHTE: Record, Recht[]> = { KOMMANDANT: [ ...GRUNDRECHTE, "organisation.bearbeiten", "system.auditlog", "person.bearbeiten", "person.loeschen", "person.rollen", "person.ausweis", "person.export", "fahrzeug.bearbeiten", "geraet.bearbeiten", "geraet.aussondern", "pruefung.planen", "pruefung.durchfuehren", "atemschutz.lesen", "atemschutz.bearbeiten", "atemschutz.ueberwachung", "einsatz.bearbeiten", "einsatz.bericht", "ausbildung.bearbeiten", "qualifikation.bearbeiten", "kasse.lesen", "datei.hochladen", "datei.loeschen", "termin.bearbeiten", "termin.anwesenheit", "benachrichtigung.vorlagen", "wiki.bearbeiten", "ki.nutzen", "ki.protokoll", "beleg.erfassen", ], GERAETEWART: [ ...GRUNDRECHTE, "fahrzeug.bearbeiten", "geraet.bearbeiten", "geraet.aussondern", "pruefung.planen", "pruefung.durchfuehren", "datei.hochladen", "ki.nutzen", ], ATEMSCHUTZGERAETEWART: [ ...GRUNDRECHTE, "geraet.bearbeiten", "pruefung.planen", "pruefung.durchfuehren", "atemschutz.lesen", "atemschutz.bearbeiten", "atemschutz.ueberwachung", "datei.hochladen", "ki.nutzen", ], KASSIER: [ ...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen", // Die Belegerfassung gehört zur Kasse: der Kassier fotografiert die // Quittung und zeichnet gegen, was das Modell gelesen hat. "beleg.erfassen", "ki.nutzen", ], SCHRIFTFUEHRER: [ ...GRUNDRECHTE, "person.bearbeiten", "einsatz.bearbeiten", "einsatz.bericht", "ausbildung.bearbeiten", "datei.hochladen", // Der Schriftführer führt den Terminkalender der Wehr — das ist seit jeher // sein Amt, lange bevor es dafür Software gab. "termin.bearbeiten", "termin.anwesenheit", "benachrichtigung.vorlagen", // Und er ist derjenige, der Berichte schreibt — also derjenige, dem // Diktat und Berichtsentwurf am meisten nützen. "wiki.bearbeiten", "ki.nutzen", ], AUSBILDER: [ ...GRUNDRECHTE, "ausbildung.bearbeiten", "qualifikation.bearbeiten", "datei.hochladen", // Übungstermine anlegen und die Anwesenheit abhaken gehört zur Ausbildung. "termin.bearbeiten", "termin.anwesenheit", // Ausbildungsunterlagen sind der halbe Inhalt des Wikis. "wiki.bearbeiten", "ki.nutzen", ], MITGLIED: [...GRUNDRECHTE], // Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich // zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person. KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"], }; // ----------------------------------------------------------------------------- // Sitzung und Prüffunktionen // ----------------------------------------------------------------------------- /** Was die Anwendung über den gerade Angemeldeten weiß. */ export interface Sitzung { personId: string; organisationId: string; name: string; rollen: Rolle[]; /** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */ anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM"; } /** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */ export class KeinZugriffFehler extends Error { constructor( public readonly benoetigtesRecht: Recht | Recht[], nachricht?: string, ) { const rechte = Array.isArray(benoetigtesRecht) ? benoetigtesRecht.join(", ") : benoetigtesRecht; super(nachricht ?? `Fehlende Berechtigung: ${rechte}`); this.name = "KeinZugriffFehler"; } } /** Alle Rechte, die sich aus den Rollen einer Sitzung ergeben. */ export function rechteVon(rollen: readonly Rolle[]): Set { if (rollen.includes("SUPERADMIN")) return new Set(ALLE_RECHTE); const gesammelt = new Set(); for (const rolle of rollen) { if (rolle === "SUPERADMIN") continue; for (const recht of ROLLEN_RECHTE[rolle] ?? []) gesammelt.add(recht); } return gesammelt; } /** Hat die Sitzung das Recht? Der Administrator hat immer alles. */ export function hatRecht(sitzung: Sitzung | null, recht: Recht): boolean { if (!sitzung) return false; if (sitzung.rollen.includes("SUPERADMIN")) return true; return rechteVon(sitzung.rollen).has(recht); } /** Mindestens eines der Rechte. */ export function hatEinesDerRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean { return rechte.some((recht) => hatRecht(sitzung, recht)); } /** Alle genannten Rechte. */ export function hatAlleRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean { return rechte.every((recht) => hatRecht(sitzung, recht)); } /** Wirft, wenn das Recht fehlt. Für Server-Actions und tRPC-Prozeduren. */ export function verlangeRecht(sitzung: Sitzung | null, recht: Recht): asserts sitzung is Sitzung { if (!hatRecht(sitzung, recht)) throw new KeinZugriffFehler(recht); } /** Wirft, wenn keines der Rechte vorliegt. */ export function verlangeEinesDerRechte( sitzung: Sitzung | null, rechte: Recht[], ): asserts sitzung is Sitzung { if (!hatEinesDerRechte(sitzung, rechte)) throw new KeinZugriffFehler(rechte); } /** Hat die Sitzung eine dieser Rollen? Nur für Anzeigezwecke gedacht. */ export function hatRolle(sitzung: Sitzung | null, ...rollen: Rolle[]): boolean { if (!sitzung) return false; return rollen.some((rolle) => sitzung.rollen.includes(rolle)); } /** * Darf die Sitzung auf diesen Datensatz zugreifen? Ergänzt die Mandanten- * trennung der Datenbank um eine Prüfung im Code — doppelt hält besser, und * die Fehlermeldung ist verständlicher als ein leeres Ergebnis. */ export function gehoertZurOrganisation( sitzung: Sitzung | null, datensatz: { organisationId: string } | null | undefined, ): boolean { if (!sitzung || !datensatz) return false; return sitzung.organisationId === datensatz.organisationId; } /** * Die eigenen Daten darf jeder sehen, auch ohne person.lesen. Andere nur mit * dem entsprechenden Recht. */ export function darfPersonSehen(sitzung: Sitzung | null, personId: string): boolean { if (!sitzung) return false; if (sitzung.personId === personId) return true; return hatRecht(sitzung, "person.lesen"); } /** Wandelt einen beliebigen Text in eine gültige Rolle um, sonst null. */ export function alsRolle(wert: string | null | undefined): Rolle | null { if (!wert) return null; const gross = wert.trim().toUpperCase(); return (ROLLEN as readonly string[]).includes(gross) ? (gross as Rolle) : null; }