fix: Migration 0021 crashte in Produktion — falsche Rollen-Annahme

v0.38.0 verursachte einen ~3-minütigen Ausfall: Migration 0021 nahm an,
die Anwendung verbinde überall als Postgres-Superuser "postgres" (nur
lokal via sudo geprüft, nicht die tatsächliche Produktions-DATABASE_URL)
und versuchte dort eine neue Rolle anzulegen - production verbindet
aber über die nicht-privilegierte, tabellenbesitzende Rolle "deklarix"
ohne CREATEROLE, das INSERT/CREATE ROLE schlug fehl und der Dienst
crash-loopte im "dirty migration"-Zustand. Kein Datenverlust (Postgres
hat die DDL-Transaktion selbst zurückgerollt), Dienst wurde auf v0.37.0
zurückgestuft und stabilisiert.

Fix: Rollen-Anlage ist jetzt an eine CREATEROLE-Prüfung gekoppelt und
wird bei fehlender Berechtigung übersprungen statt zu scheitern.
Zusätzlich FORCE ROW LEVEL SECURITY auf jeder Tabelle - das bindet auch
den Tabellenbesitzer (wie Productions "deklarix"), ganz ohne die
zusätzliche Rolle. Produktivbetrieb braucht dadurch jetzt gar keinen
manuellen Schritt mehr. Erneut end-to-end verifiziert, diesmal
zusätzlich produktionsgetreu simuliert (temporäre nicht-privilegierte,
tabellenbesitzende Rolle lokal).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-01 09:38:51 +02:00
parent fe28278615
commit b0d6b00045
3 changed files with 137 additions and 53 deletions

View File

@@ -1,36 +1,47 @@
-- 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 '<generiertes Secret>';
-- Danach DATABASE_URL_APP in der jeweiligen deklarix.env eintragen.
-- 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 NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
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
$$;
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;
-- 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 '<generiertes Secret>';
-- 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
@@ -46,6 +57,7 @@ CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
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
@@ -55,6 +67,7 @@ CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
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
@@ -64,6 +77,7 @@ CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
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
@@ -73,6 +87,7 @@ CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
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
@@ -82,6 +97,7 @@ CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
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
@@ -91,6 +107,7 @@ CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
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
@@ -100,6 +117,7 @@ CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
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
@@ -115,6 +133,7 @@ CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
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
@@ -126,6 +145,7 @@ CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
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)
@@ -133,6 +153,7 @@ CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
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)
@@ -140,6 +161,7 @@ CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
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)
@@ -147,6 +169,7 @@ CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
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