-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security). -- WICHTIG: RLS-Policies wirken NIE bei Postgres-Superusern und NIE beim -- Tabellenbesitzer ohne FORCE ROW LEVEL SECURITY — und FORCE wirkt -- seinerseits NICHT bei Superusern (das ist eine harte, nicht -- überschreibbare Postgres-Regel). Migrationen und die bisherige -- Anwendungs-DATABASE_URL laufen als Superuser "postgres" (Tabellen- -- besitzer) — für diese Rolle ist RLS wirkungslos, ganz gleich wie die -- Policies aussehen. Deshalb legt diese Migration zusätzlich eine neue, -- eingeschränkte Rolle "deklarix_app" an (kein Superuser, kein -- Tabellenbesitzer, NOBYPASSRLS) — NUR für diese Rolle greifen die -- Policies unten tatsächlich. Migrationen laufen weiterhin über die -- bisherige privilegierte DATABASE_URL; die laufende Anwendung muss auf -- die neue, eingeschränkte Rolle umgestellt werden (neue Umgebungs- -- variable DATABASE_URL_APP, siehe cmd/deklarix/main.go) — ohne diesen -- Wechsel ist diese Migration reine Dokumentation ohne Wirkung. -- -- Die Rolle wird bewusst OHNE Passwort angelegt (NOLOGIN) — ein -- Klartext-Passwort gehört nicht in eine versionierte, für jeden mit -- Repo-Zugriff lesbare Migrationsdatei. Login-Fähigkeit + Passwort -- werden einmalig manuell je Umgebung gesetzt: -- ALTER ROLE deklarix_app WITH LOGIN PASSWORD ''; -- Danach DATABASE_URL_APP in der jeweiligen deklarix.env eintragen. DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN; END IF; END $$; GRANT USAGE ON SCHEMA public TO deklarix_app; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app; -- ─── 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; 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; 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; 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; 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; 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; 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; -- ─── 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; -- ─── 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; 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; 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; 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; -- ─── 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.