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:
Florian Wilde
2026-09-03 00:13:53 +02:00
co-authored by Claude Opus 5
commit 845942f36c
84 changed files with 21433 additions and 0 deletions
@@ -0,0 +1,409 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "citext";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "unaccent";
-- CreateEnum
CREATE TYPE "RolleSchluessel" AS ENUM ('SUPERADMIN', 'KOMMANDANT', 'GERAETEWART', 'ATEMSCHUTZGERAETEWART', 'KASSIER', 'SCHRIFTFUEHRER', 'AUSBILDER', 'MITGLIED', 'KIOSK');
-- CreateEnum
CREATE TYPE "AusweisTokenTyp" AS ENUM ('PIN', 'RFID');
-- CreateEnum
CREATE TYPE "AnmeldeArt" AS ENUM ('LOKAL', 'OIDC', 'KIOSK_PIN', 'KIOSK_RFID', 'SYSTEM');
-- CreateEnum
CREATE TYPE "AuditAktion" AS ENUM ('ERSTELLT', 'GEAENDERT', 'GELOESCHT', 'WIEDERHERGESTELLT', 'ANGEMELDET', 'ABGEMELDET', 'ANMELDUNG_FEHLGESCHLAGEN', 'EXPORTIERT', 'DATEI_HOCHGELADEN', 'DATEI_ABGERUFEN', 'BERECHTIGUNG_GEAENDERT');
-- CreateEnum
CREATE TYPE "AkteurTyp" AS ENUM ('PERSON', 'SYSTEM', 'WORKER', 'KIOSK', 'API');
-- CreateEnum
CREATE TYPE "GeraetStatus" AS ENUM ('EINSATZBEREIT', 'PRUEFUNG_FAELLIG', 'GESPERRT', 'AUSGESONDERT', 'IN_REPARATUR');
-- CreateEnum
CREATE TYPE "FahrzeugArt" AS ENUM ('LOESCHFAHRZEUG', 'DREHLEITER', 'RUESTWAGEN', 'GERAETEWAGEN', 'MANNSCHAFTSTRANSPORT', 'EINSATZLEITWAGEN', 'WECHSELLADER', 'ANHAENGER', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "Dienstgrad" AS ENUM ('FEUERWEHRANWAERTER', 'FEUERWEHRMANN', 'OBERFEUERWEHRMANN', 'HAUPTFEUERWEHRMANN', 'LOESCHMEISTER', 'OBERLOESCHMEISTER', 'HAUPTLOESCHMEISTER', 'BRANDMEISTER', 'OBERBRANDMEISTER', 'HAUPTBRANDMEISTER', 'KEIN');
-- CreateTable
CREATE TABLE "organisation" (
"id" UUID NOT NULL,
"slug" VARCHAR(64) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"kurzname" VARCHAR(60) NOT NULL,
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"landkreis" VARCHAR(120),
"bundesland" VARCHAR(60) NOT NULL DEFAULT 'Bayern',
"telefon" VARCHAR(40),
"email" VARCHAR(200),
"ilsKennung" VARCHAR(40),
"funkPraefix" VARCHAR(80),
"zeitzone" VARCHAR(60) NOT NULL DEFAULT 'Europe/Berlin',
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "organisation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "person" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorname" VARCHAR(100) NOT NULL,
"nachname" VARCHAR(100) NOT NULL,
"mitgliedsnummer" VARCHAR(30),
"geburtsdatum" DATE,
"dienstgrad" "Dienstgrad" NOT NULL DEFAULT 'KEIN',
"email" VARCHAR(200),
"telefon" VARCHAR(40),
"mobil" VARCHAR(40),
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"eintrittsdatum" DATE,
"austrittsdatum" DATE,
"abteilung" VARCHAR(60),
"passwortHash" VARCHAR(120),
"passwortGeaendertAm" TIMESTAMPTZ(3),
"oidcSubject" VARCHAR(255),
"oidcGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
"letzteAnmeldung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"avatarDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rolle" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"schluessel" "RolleSchluessel" NOT NULL,
"name" VARCHAR(100) NOT NULL,
"beschreibung" VARCHAR(500),
"systemRolle" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rolle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "person_rolle" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"rolleId" UUID NOT NULL,
"zugewiesenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zugewiesenVonId" UUID,
"gueltigBis" TIMESTAMPTZ(3),
"ausOidcGruppe" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "person_rolle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "berechtigung" (
"id" UUID NOT NULL,
"schluessel" VARCHAR(80) NOT NULL,
"bereich" VARCHAR(60) NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"beschreibung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "berechtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rolle_berechtigung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rolleId" UUID NOT NULL,
"berechtigungId" UUID NOT NULL,
"gewaehrt" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rolle_berechtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ausweis_token" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"typ" "AusweisTokenTyp" NOT NULL,
"tokenHash" VARCHAR(120) NOT NULL,
"kennzeichen" VARCHAR(20),
"bezeichnung" VARCHAR(120),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"gueltigAb" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gueltigBis" TIMESTAMPTZ(3),
"letzteNutzung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "ausweis_token_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_log" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"akteurTyp" "AkteurTyp" NOT NULL DEFAULT 'PERSON',
"akteurPersonId" UUID,
"akteurName" VARCHAR(200),
"anmeldeArt" "AnmeldeArt",
"aktion" "AuditAktion" NOT NULL,
"entitaet" VARCHAR(80) NOT NULL,
"entitaetId" UUID,
"beschreibung" VARCHAR(500),
"vorher" JSONB,
"nachher" JSONB,
"kontext" JSONB,
"ipAdresse" INET,
"userAgent" VARCHAR(400),
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "datei" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bucket" VARCHAR(80) NOT NULL,
"objektSchluessel" VARCHAR(500) NOT NULL,
"dateiname" VARCHAR(300) NOT NULL,
"mimeTyp" VARCHAR(160) NOT NULL,
"groesseBytes" BIGINT NOT NULL,
"pruefsumme" VARCHAR(64),
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"kategorie" VARCHAR(60),
"hochgeladenVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "datei_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fahrzeug" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"funkrufname" VARCHAR(120) NOT NULL,
"bezeichnung" VARCHAR(120) NOT NULL,
"art" "FahrzeugArt" NOT NULL DEFAULT 'LOESCHFAHRZEUG',
"kennzeichen" VARCHAR(20),
"hersteller" VARCHAR(120),
"baujahr" INTEGER,
"tuevBis" DATE,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "fahrzeug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraet" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"inventarnummer" VARCHAR(60) NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"hersteller" VARCHAR(120),
"seriennummer" VARCHAR(120),
"herstellungsdatum" DATE,
"indienststellung" DATE,
"status" "GeraetStatus" NOT NULL DEFAULT 'EINSATZBEREIT',
"pruefintervallMonate" INTEGER,
"naechstePruefung" DATE,
"lebensdauerJahre" INTEGER,
"fahrzeugId" UUID,
"lagerort" VARCHAR(160),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraet_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organisation_slug_key" ON "organisation"("slug");
-- CreateIndex
CREATE INDEX "organisation_aktiv_idx" ON "organisation"("aktiv");
-- CreateIndex
CREATE INDEX "person_organisationId_nachname_vorname_idx" ON "person"("organisationId", "nachname", "vorname");
-- CreateIndex
CREATE INDEX "person_organisationId_aktiv_idx" ON "person"("organisationId", "aktiv");
-- CreateIndex
CREATE INDEX "person_oidcSubject_idx" ON "person"("oidcSubject");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_email_key" ON "person"("organisationId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_oidcSubject_key" ON "person"("organisationId", "oidcSubject");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_mitgliedsnummer_key" ON "person"("organisationId", "mitgliedsnummer");
-- CreateIndex
CREATE INDEX "rolle_organisationId_idx" ON "rolle"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "rolle_organisationId_schluessel_key" ON "rolle"("organisationId", "schluessel");
-- CreateIndex
CREATE INDEX "person_rolle_organisationId_personId_idx" ON "person_rolle"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "person_rolle_organisationId_rolleId_idx" ON "person_rolle"("organisationId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "person_rolle_personId_rolleId_key" ON "person_rolle"("personId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "berechtigung_schluessel_key" ON "berechtigung"("schluessel");
-- CreateIndex
CREATE INDEX "berechtigung_bereich_idx" ON "berechtigung"("bereich");
-- CreateIndex
CREATE INDEX "rolle_berechtigung_organisationId_rolleId_idx" ON "rolle_berechtigung"("organisationId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "rolle_berechtigung_rolleId_berechtigungId_key" ON "rolle_berechtigung"("rolleId", "berechtigungId");
-- CreateIndex
CREATE INDEX "ausweis_token_organisationId_personId_idx" ON "ausweis_token"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "ausweis_token_organisationId_typ_aktiv_idx" ON "ausweis_token"("organisationId", "typ", "aktiv");
-- CreateIndex
CREATE INDEX "audit_log_organisationId_zeitpunkt_idx" ON "audit_log"("organisationId", "zeitpunkt" DESC);
-- CreateIndex
CREATE INDEX "audit_log_organisationId_entitaet_entitaetId_idx" ON "audit_log"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "audit_log_organisationId_akteurPersonId_idx" ON "audit_log"("organisationId", "akteurPersonId");
-- CreateIndex
CREATE INDEX "datei_organisationId_entitaet_entitaetId_idx" ON "datei"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "datei_organisationId_kategorie_idx" ON "datei"("organisationId", "kategorie");
-- CreateIndex
CREATE UNIQUE INDEX "datei_bucket_objektSchluessel_key" ON "datei"("bucket", "objektSchluessel");
-- CreateIndex
CREATE INDEX "fahrzeug_organisationId_aktiv_idx" ON "fahrzeug"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "fahrzeug_organisationId_funkrufname_key" ON "fahrzeug"("organisationId", "funkrufname");
-- CreateIndex
CREATE INDEX "geraet_organisationId_status_idx" ON "geraet"("organisationId", "status");
-- CreateIndex
CREATE INDEX "geraet_organisationId_naechstePruefung_idx" ON "geraet"("organisationId", "naechstePruefung");
-- CreateIndex
CREATE INDEX "geraet_organisationId_fahrzeugId_idx" ON "geraet"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "geraet_organisationId_inventarnummer_key" ON "geraet"("organisationId", "inventarnummer");
-- AddForeignKey
ALTER TABLE "person" ADD CONSTRAINT "person_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person" ADD CONSTRAINT "person_avatarDateiId_fkey" FOREIGN KEY ("avatarDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle" ADD CONSTRAINT "rolle_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_rolleId_fkey" FOREIGN KEY ("rolleId") REFERENCES "rolle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_zugewiesenVonId_fkey" FOREIGN KEY ("zugewiesenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_rolleId_fkey" FOREIGN KEY ("rolleId") REFERENCES "rolle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_berechtigungId_fkey" FOREIGN KEY ("berechtigungId") REFERENCES "berechtigung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ausweis_token" ADD CONSTRAINT "ausweis_token_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ausweis_token" ADD CONSTRAINT "ausweis_token_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_akteurPersonId_fkey" FOREIGN KEY ("akteurPersonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "datei" ADD CONSTRAINT "datei_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "datei" ADD CONSTRAINT "datei_hochgeladenVonId_fkey" FOREIGN KEY ("hochgeladenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,156 @@
-- =============================================================================
-- Row Level Security: Mandantentrennung auf Datenbankebene.
--
-- Warum auf Datenbankebene und nicht nur im Code?
-- Weil ein vergessener WHERE-Filter sonst Daten einer fremden Wehr ausliefert.
-- Mit RLS ist der schlimmste Fall ein leeres Ergebnis, nicht ein Datenleck.
--
-- Funktionsweise:
-- Die Anwendung verbindet sich als "florian_app" (NOBYPASSRLS) und setzt zu
-- Beginn jeder Transaktion
-- SET LOCAL app.current_organisation = '<uuid>';
-- Siehe src/lib/db.ts. Ohne gesetzten Wert liefert jede Abfrage null Zeilen.
--
-- Der Eigentümer der Tabellen (POSTGRES_USER) umgeht RLS. Das ist gewollt:
-- Migrationen und das Seed-Skript müssen über alle Mandanten hinweg
-- arbeiten. Deshalb steht hier bewusst kein FORCE ROW LEVEL SECURITY.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Hilfsfunktionen
-- -----------------------------------------------------------------------------
-- Liest die aktive Organisation aus der Sitzung. Ist nichts gesetzt oder steht
-- dort kein gültiges UUID, kommt NULL zurück — und NULL vergleicht sich mit
-- nichts, die Abfrage liefert also keine Zeilen.
CREATE OR REPLACE FUNCTION aktuelle_organisation()
RETURNS uuid
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
wert text;
BEGIN
wert := current_setting('app.current_organisation', true);
IF wert IS NULL OR wert = '' THEN
RETURN NULL;
END IF;
RETURN wert::uuid;
EXCEPTION
WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION aktuelle_organisation() IS
'Aktive Organisation der Sitzung, gesetzt per SET LOCAL app.current_organisation.';
-- Verhindert Änderungen am Audit-Log — auch durch den Tabelleneigentümer.
CREATE OR REPLACE FUNCTION audit_log_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. % ist auf audit_log nicht zulaessig.',
TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;
-- -----------------------------------------------------------------------------
-- Mandantenpflichtige Tabellen
-- -----------------------------------------------------------------------------
-- Die Organisation selbst: hier ist die id die Mandantenspalte.
ALTER TABLE "organisation" ENABLE ROW LEVEL SECURITY;
CREATE POLICY organisation_mandant ON "organisation"
FOR ALL
USING ("id" = aktuelle_organisation())
WITH CHECK ("id" = aktuelle_organisation());
-- Alle übrigen Tabellen hängen über organisationId am Mandanten.
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'person',
'rolle',
'person_rolle',
'rolle_berechtigung',
'ausweis_token',
'datei',
'fahrzeug',
'geraet'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Audit-Log: mandantengetrennt UND append-only
-- Bewusst nur SELECT und INSERT — es gibt keine Policy für UPDATE und DELETE,
-- die Anwendungsrolle kann also gar nicht ändern oder löschen.
-- -----------------------------------------------------------------------------
ALTER TABLE "audit_log" ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_log_lesen ON "audit_log"
FOR SELECT
USING ("organisationId" = aktuelle_organisation());
CREATE POLICY audit_log_schreiben ON "audit_log"
FOR INSERT
WITH CHECK ("organisationId" = aktuelle_organisation());
CREATE TRIGGER audit_log_kein_update
BEFORE UPDATE ON "audit_log"
FOR EACH ROW EXECUTE FUNCTION audit_log_unveraenderlich();
CREATE TRIGGER audit_log_kein_delete
BEFORE DELETE ON "audit_log"
FOR EACH ROW EXECUTE FUNCTION audit_log_unveraenderlich();
COMMENT ON TABLE "audit_log" IS
'Append-only. Aenderungen und Loeschungen sind per Trigger gesperrt. '
'Die Aufbewahrungsfrist raeumt der Worker ab, nachdem er exportiert hat.';
-- -----------------------------------------------------------------------------
-- Berechtigungskatalog: systemweit, kein Mandantenbezug.
-- Er beschreibt, was die Software kann — nicht, was eine Wehr besitzt.
-- Deshalb kein RLS, aber nur Lesezugriff für die Anwendung.
-- -----------------------------------------------------------------------------
COMMENT ON TABLE "berechtigung" IS
'Systemweiter Katalog der Einzelrechte. Bewusst ohne organisationId: '
'mandantenbezogen wird es erst ueber rolle_berechtigung.';
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle. Existiert die Rolle nicht (z. B. bei einer
-- Entwicklungsdatenbank ohne den Init-Container), wird der Block übersprungen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT USAGE ON SCHEMA public TO florian_app';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
-- Der Katalog ist für die Anwendung nur lesbar.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
-- Der Audit-Log ist für die Anwendung nur schreib- und lesbar.
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,60 @@
-- =============================================================================
-- Kontrollierte Ausnahme vom Änderungsverbot des Audit-Logs.
--
-- Ausgangslage: Der Trigger aus 20260902000100_rls_policies verbietet UPDATE
-- und DELETE auf audit_log. Das ist richtig — nachträglich verändern soll den
-- Log niemand.
--
-- Es gibt aber genau einen Vorgang, der löschen können muss: der
-- Aufbewahrungsjob des Workers. Nach AUDIT_AUFBEWAHRUNG_TAGE exportiert er die
-- alten Einträge nach MinIO und räumt sie danach ab. Ohne diese Möglichkeit
-- wächst die Tabelle unbegrenzt, und das Löschkonzept nach DSGVO ließe sich
-- nicht umsetzen.
--
-- Der Ausweg ist ein ausdrücklicher Schalter, den nur der Eigentümer-Zugang
-- setzen kann und der nur für die laufende Transaktion gilt:
--
-- BEGIN;
-- SELECT set_config('app.audit_bereinigung', 'an', true);
-- DELETE FROM audit_log WHERE zeitpunkt < now() - interval '10 years';
-- COMMIT;
--
-- UPDATE bleibt unter allen Umständen verboten. Ein Eintrag kann verschwinden,
-- nachdem er gesichert wurde — verfälscht werden kann er nie.
-- =============================================================================
CREATE OR REPLACE FUNCTION audit_log_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Änderungen sind ausnahmslos gesperrt.
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. UPDATE ist auf audit_log nicht zulaessig.'
USING ERRCODE = 'insufficient_privilege';
END IF;
-- Löschen nur im ausdrücklich gekennzeichneten Aufbewahrungslauf.
IF TG_OP = 'DELETE' THEN
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. DELETE ist nur im Aufbewahrungslauf zulaessig '
'(SET LOCAL app.audit_bereinigung = ''an'').'
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION audit_log_unveraenderlich() IS
'Sperrt UPDATE auf audit_log dauerhaft und DELETE ausserhalb des Aufbewahrungslaufs.';
-- Die Anwendungsrolle darf den Schalter gar nicht erst setzen: sie hat kein
-- DELETE-Recht auf audit_log (siehe vorherige Migration). Der Schalter wirkt
-- also nur fuer den Eigentuemer-Zugang, den ausschliesslich Worker und
-- Migrationen benutzen.