Files
FireAdministration/src/lib/authz.ts
T
Florian WildeandClaude Opus 5 f7ccd9a908 Phase 8: Lokale KI
Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

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

406 lines
15 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" },
// 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<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",
"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<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;
}