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,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.
|
||||
Reference in New Issue
Block a user