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

@@ -635,16 +635,22 @@ und live per curl gegen echten Server + Postgres verifiziert.
## Row-Level-Security (2026-09-01, Migration 0021)
**Kritischer Fund vor der Umsetzung:** Sowohl lokal als auch auf dem
Produktivserver verbindet sich die Anwendung als `postgres`-Rolle — ein
echter Postgres-**Superuser**. Superuser umgehen RLS-Policies *immer*,
unabhängig von `FORCE ROW LEVEL SECURITY` (das wirkt nur beim
Tabellenbesitzer, nicht bei Superusern — eine harte, nicht
überschreibbare Postgres-Regel). Policies allein hätten also nichts
bewirkt. Migration 0021 legt deshalb zusätzlich eine neue,
eingeschränkte Rolle **`deklarix_app`** an (kein Superuser, kein
Tabellenbesitzer, `NOBYPASSRLS`, zunächst `NOLOGIN`) — nur für diese
Rolle greifen die Policies tatsächlich.
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
nie beim Tabellenbesitzer ohne `FORCE ROW LEVEL SECURITY` (beides harte,
nicht überschreibbare Postgres-Regeln). Lokal verbindet die Anwendung
als echter Superuser `postgres` (Docker-Testumgebung) — dort hätte
`FORCE` allein nichts bewirkt. Auf dem Produktivserver verbindet sie
dagegen über eine eigene, **nicht-privilegierte** Rolle `deklarix`
(kein Superuser), die zugleich Eigentümerin der Tabellen ist — dort
reicht `FORCE` aus. **Diese Erkenntnis kam erst nach einem
Fehlversuch** (siehe "Incident" weiter unten) — die ursprüngliche
Annahme, auch Produktion verbinde als Superuser, war falsch und beruhte
auf einer Prüfung der falschen Rolle. Migration 0021 deckt seit der
Korrektur beide Fälle ab: `FORCE ROW LEVEL SECURITY` auf jeder Tabelle
(reicht für Produktion), plus optional eine neue, eingeschränkte Rolle
**`deklarix_app`** (kein Superuser, kein Tabellenbesitzer,
`NOBYPASSRLS`, zunächst `NOLOGIN`) für Umgebungen mit einer
Superuser-Verbindung wie lokal.
**Architektur:** `internal/store/tenant_scope.go``Store.db(ctx)`
liefert entweder die aktive Transaktion (falls `WithTenantScope` sie
@@ -668,7 +674,7 @@ werden frühere Schritte desselben Requests zurückgerollt (vorher: keine
Transaktion, ein halb fehlgeschlagener Handler konnte einen verwaisten
Account ohne Nutzer hinterlassen).
**Geschützte Tabellen** (`ENABLE ROW LEVEL SECURITY` + Policy
**Geschützte Tabellen** (`ENABLE` + `FORCE ROW LEVEL SECURITY` + Policy
`tenant_isolation`): `antrag`, `registereintrag`, `abteilung`,
`werkzeug_sperre`, `genehmiger_rolle`, `freigabe_regel`,
`loeschfrist_einstellung` (direkte `account_id`-Prüfung), `werkzeug`
@@ -710,19 +716,56 @@ eingeschränkte Rolle und überspringen sich selbst sauber, wenn
`DATABASE_URL_APP` nicht gesetzt ist (analog zum bestehenden
`DATABASE_URL`-Skip-Muster).
**Produktivbetrieb — noch ausstehender manueller Schritt.** Die
Migration allein bewirkt in Produktion noch NICHTS (die App verbindet
weiterhin als `postgres`-Superuser, `DATABASE_URL_APP` ist nicht
gesetzt). Um RLS tatsächlich scharf zu schalten:
1. `ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<neu generiertes Secret>';`
einmalig auf dem Zielserver ausführen (das Passwort steht nicht im
Code/in Migrationen — Secrets gehören nicht in ein versioniertes
Repo).
2. `DATABASE_URL_APP=postgres://deklarix_app:<secret>@<host>/deklarix?...`
in `/etc/deklarix/deklarix.env` eintragen.
3. Dienst neu starten.
`DATABASE_URL` (Migrationen, DDL-Rechte) bleibt unverändert auf der
bisherigen, privilegierten Verbindung.
**Incident 2026-09-01 (~3 Minuten Downtime) und Korrektur.** Die erste
Fassung der Migration ging fälschlich davon aus, dass die Anwendung
überall als Postgres-**Superuser** `postgres` verbindet (das hatte ich
nur lokal und via `sudo -u postgres psql` auf dem Server geprüft — das
ist aber ein SSH/OS-Login-Check, nicht die tatsächliche
`DATABASE_URL`-Rolle der Anwendung). Tatsächlich verbindet Produktion
über eine eigene, **nicht-privilegierte** Rolle `deklarix` (kein
Superuser, kein `CREATEROLE`), die zugleich Eigentümerin aller Tabellen
ist. Die Migration versuchte dort `CREATE ROLE deklarix_app`
auszuführen, scheiterte mit "permission denied to create role", blieb
im `dirty`-Zustand hängen und der Dienst crash-loopte beim Start
(09:2709:30 Uhr). Behoben durch: `schema_migrations` manuell auf
Version 20 zurückgesetzt, Paket auf v0.37.0 zurückgestuft, Dienst
stabilisiert — kein Datenverlust, Postgres hatte die fehlgeschlagene
Migration als DDL-Transaktion bereits sauber selbst zurückgerollt, nur
golang-migrates eigene Versions-Buchführung musste von Hand korrigiert
werden.
**Korrigierte, robustere Migration:** das Anlegen von `deklarix_app` ist
jetzt an eine Prüfung gekoppelt (`SELECT ... WHERE rolname = current_user
AND rolcreaterole`) und wird bei fehlendem `CREATEROLE` übersprungen
(`RAISE NOTICE`, kein Fehler) statt die ganze Migration scheitern zu
lassen. Zusätzlich bekommt jede Tabelle jetzt **`FORCE ROW LEVEL
SECURITY`** (vorher nur `ENABLE`) — das bindet auch den **Tabellen-
besitzer** an die Policies, sofern er kein Superuser ist. Damit deckt
eine einzige Migration beide Fälle ab:
- **Produktion** (`deklarix`, Tabellenbesitzer, kein Superuser): `FORCE`
allein reicht bereits aus. **Kein manueller Schritt nötig** — nach dem
Deploy dieser Migration ist RLS dort sofort aktiv.
- **Lokales Docker-Postgres** (`postgres`-Superuser, für den `FORCE`
wirkungslos bleibt): `deklarix_app` wird zusätzlich angelegt, für
lokale Tests weiterhin per `DATABASE_URL_APP` nutzbar.
**Erneut end-to-end verifiziert nach der Korrektur**, diesmal
zusätzlich produktionsgetreu: eine zweite, temporäre lokale Rolle
(`deklarix_sim`, kein Superuser, kein `CREATEROLE`, Eigentümerin einer
frischen Test-Datenbank — exakt Produktions-Rechtemodell) durchlief die
komplette Migrationskette 00010021 fehlerfrei, **und** die Isolation
griff nachweislich auch für sie als Tabellenbesitzerin (Kontext A sah
ausschließlich Zeile A, trotz voller Tabellen-Eigentümerschaft). Vorher
war nur der `deklarix_app`-Pfad (Nicht-Eigentümer-Rolle) bewiesen, nicht
der tatsächliche Produktions-Pfad (Eigentümer-Rolle + FORCE) — genau die
Lücke, die den Incident verursachte.
**Lehre für künftige Prüfungen dieser Art:** "welche DB-Rolle verwendet
die Anwendung" per `sudo -u postgres psql` zu beantworten prüft die
falsche Sache — maßgeblich ist ausschließlich die Rolle **in der
tatsächlichen `DATABASE_URL`** (hier: `cat /etc/deklarix/deklarix.env`
bzw. `SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE
rolname = 'deklarix'`, nicht `current_user` über einen andere Anmeldung).
---