-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security). -- -- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE -- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW -- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab: -- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B. -- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat -- sie über die Migrationen selbst angelegt) — für sie reicht FORCE -- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig. -- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als -- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit -- "postgres") — für den wirkt FORCE nicht (Superuser sind davon -- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann -- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt -- werden, für die die Policies unabhängig von FORCE gelten. -- -- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE -- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten -- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist -- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür -- keine Berechtigung hat. DO $$ BEGIN IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN; END IF; EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app'; EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app'; EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app'; ELSE RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user; END IF; END $$; -- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden -- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten -- Migration (Klartext-Secret gehört nicht ins Repo): -- ALTER ROLE deklarix_app WITH LOGIN PASSWORD ''; -- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env -- eintragen. Für Umgebungen, in denen die Anwendung bereits als -- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig — -- FORCE ROW LEVEL SECURITY unten reicht dort aus. -- ─── Tabellen MIT direkter account_id-Spalte ─────────────────────────── -- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE -- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid -- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt -- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die -- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers. CREATE POLICY tenant_isolation ON antrag FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE antrag ENABLE ROW LEVEL SECURITY; ALTER TABLE antrag FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY; ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON abteilung FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY; ALTER TABLE abteilung FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY; ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY; ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY; ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ); ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY; ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY; -- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ────────── -- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der -- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein -- Mandant nur seine eigenen — sonst könnte ein Mandant über einen -- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern. CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING ( account_id IS NULL OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid OR current_setting('app.is_betreiber', true) = 'true' ) WITH CHECK ( (account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true') OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid ); ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY; ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY; -- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ─── -- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine -- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe -- Mandantengrenze, ohne die Bedingung hier zu duplizieren. CREATE POLICY tenant_isolation ON bewertung FOR ALL USING ( antrag_id IN (SELECT id FROM antrag) ) WITH CHECK ( antrag_id IN (SELECT id FROM antrag) ); ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY; ALTER TABLE bewertung FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING ( antrag_id IN (SELECT id FROM antrag) ) WITH CHECK ( antrag_id IN (SELECT id FROM antrag) ); ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY; ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING ( antrag_id IN (SELECT id FROM antrag) ) WITH CHECK ( antrag_id IN (SELECT id FROM antrag) ); ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY; ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING ( genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle) ) WITH CHECK ( genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle) ); ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY; ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY; -- ─── Bewusst OHNE RLS ─────────────────────────────────────────────────── -- account: hat keine account_id-Spalte (ist selbst der Mandant) und -- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor -- die neue ID überhaupt bekannt ist. -- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE -- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch -- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht -- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel. -- session, password_reset_token: werden ausschließlich über einen -- kryptographisch zufälligen, praktisch unerratbaren Token gesucht, -- nicht über eine sequentielle ID — dieselbe Begründung wie app_user. -- audit_log: plattformweites Protokoll, wird ausschließlich vom -- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine -- eigene account_id-Spalte.