Files
Florian WildeandClaude Opus 5 845942f36c 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>
2026-09-03 00:13:53 +02:00

157 lines
6.0 KiB
PL/PgSQL

-- =============================================================================
-- 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;
$$;