Files
FireAdministration/src/lib/authz.ts
T
Florian WildeandClaude Opus 5 791523027f Terminal im Geraetehaus mit Offline-Betrieb ergaenzen
Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:35:48 +02:00

343 lines
13 KiB
TypeScript

/**
* 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<Rolle, string> = {
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<Rolle, string> = {
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: <bereich>.<taetigkeit>
* 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" },
} 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",
];
/**
* 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<Exclude<Rolle, "SUPERADMIN">, 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",
],
GERAETEWART: [
...GRUNDRECHTE,
"fahrzeug.bearbeiten",
"geraet.bearbeiten",
"geraet.aussondern",
"pruefung.planen",
"pruefung.durchfuehren",
"datei.hochladen",
],
ATEMSCHUTZGERAETEWART: [
...GRUNDRECHTE,
"geraet.bearbeiten",
"pruefung.planen",
"pruefung.durchfuehren",
"atemschutz.lesen",
"atemschutz.bearbeiten",
"atemschutz.ueberwachung",
"datei.hochladen",
],
KASSIER: [...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen"],
SCHRIFTFUEHRER: [
...GRUNDRECHTE,
"person.bearbeiten",
"einsatz.bearbeiten",
"einsatz.bericht",
"ausbildung.bearbeiten",
"datei.hochladen",
],
AUSBILDER: [
...GRUNDRECHTE,
"ausbildung.bearbeiten",
"qualifikation.bearbeiten",
"datei.hochladen",
],
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<Recht> {
if (rollen.includes("SUPERADMIN")) return new Set(ALLE_RECHTE);
const gesammelt = new Set<Recht>();
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;
}