Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* 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" },
|
||||
"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 — die Rolle KASSIER ist in Phase 0 gefordert, ein Kassenmodul ist
|
||||
// in Phase-0.md bis Phase-8.md aber nicht vorgesehen. Die Rechte stehen hier
|
||||
// bereits bereit, damit die Rolle nicht leer bleibt. Siehe docs/ROADMAP.md.
|
||||
"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",
|
||||
"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;
|
||||
}
|
||||
Reference in New Issue
Block a user