From fe28278615dc89aecc896685b50cfdcaa048dab1 Mon Sep 17 00:00:00 2001 From: noroot Date: Tue, 1 Sep 2026 09:22:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Row-Level-Security=20f=C3=BCr=20Mandant?= =?UTF-8?q?enisolation=20auf=20DB-Ebene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Postgres-RLS-Policies auf allen Tabellen mit echten Mandanten- Geschäftsdaten (antrag und alles darüber verkettete, abteilung, werkzeug/werkzeug_sperre, genehmiger_rolle/freigabe_regel, loeschfrist_einstellung). Kritischer Fund vor der Umsetzung: die Anwendung verbindet als postgres-Superuser, der RLS immer umgeht - Migration 0021 legt deshalb zusätzlich eine eingeschränkte Rolle "deklarix_app" an, nur für die greifen die Policies tatsächlich. internal/store/tenant_scope.go: WithTenantScope öffnet eine Transaktion und setzt Sitzungsvariablen (app.account_id/app.is_betreiber) per set_config mit Parameterbindung; alle Store-Methoden laufen jetzt über s.db(ctx) statt direkt s.Pool. Jede require*-Middleware umschließt die komplette Handler-Ausführung damit - jeder Request läuft dadurch auch atomar in einer Transaktion (positiver Nebeneffekt). Live end-to-end verifiziert (echter HTTP-Server + DATABASE_URL_APP auf die eingeschränkte Rolle gesetzt): zwei Firmen registriert, Isolation über Abteilung/Antrag/Bewertung bestätigt, zentraler NULL-Katalog für beide sichtbar. Produktivbetrieb braucht noch einen manuellen Schritt (Passwort für deklarix_app setzen + DATABASE_URL_APP konfigurieren, siehe CLAUDE.md) - die Migration allein aktiviert noch nichts, solange die App weiter als Superuser verbindet. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 104 ++++++++++- cmd/deklarix/main.go | 14 +- internal/store/abteilung.go | 8 +- internal/store/account.go | 12 +- internal/store/antrag.go | 12 +- internal/store/auditlog.go | 4 +- internal/store/bewertung.go | 4 +- internal/store/entscheidung.go | 6 +- internal/store/freigabe.go | 36 ++-- internal/store/loeschfrist.go | 4 +- .../0021_row_level_security.down.sql | 40 +++++ .../migrations/0021_row_level_security.up.sql | 164 ++++++++++++++++++ internal/store/password_reset.go | 6 +- internal/store/registereintrag.go | 4 +- internal/store/session.go | 8 +- internal/store/tenant_scope.go | 104 +++++++++++ internal/store/tenant_scope_test.go | 152 ++++++++++++++++ internal/store/user.go | 12 +- internal/store/werkzeug.go | 20 +-- internal/web/auth_handlers.go | 68 +++++--- internal/web/middleware.go | 28 ++- internal/web/server.go | 9 + internal/web/server_test.go | 11 ++ 23 files changed, 727 insertions(+), 103 deletions(-) create mode 100644 internal/store/migrations/0021_row_level_security.down.sql create mode 100644 internal/store/migrations/0021_row_level_security.up.sql create mode 100644 internal/store/tenant_scope.go create mode 100644 internal/store/tenant_scope_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 24804fd..f8e418f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -633,6 +633,99 @@ 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. + +**Architektur:** `internal/store/tenant_scope.go` — `Store.db(ctx)` +liefert entweder die aktive Transaktion (falls `WithTenantScope` sie +gesetzt hat) oder den Pool direkt; alle ~69 vorherigen `s.Pool.Query/ +QueryRow/Exec`-Aufrufe wurden mechanisch auf `s.db(ctx).…` umgestellt. +`WithTenantScope(ctx, accountID, isBetreiber, fn)` öffnet eine +Transaktion, setzt zwei Sitzungsvariablen per `set_config(..., true)` +(Parameterbindung, kein String-Interpolation — SQL-Injection-frei, +automatisch auf die Transaktion begrenzt) — `app.account_id` und +`app.is_betreiber` — und committet am Ende. `SetTenantScope` erlaubt, +die Variable **mitten in einer laufenden** Transaktion nachzutragen +(nötig bei `handleRegister`: die `account_id` ist erst nach +`CreateAccount` bekannt). Jede der fünf `require*`-Middlewares +(`requirePage/-API/-Admin/-Fachebene/-Betreiber`) umschließt die +komplette Handler-Ausführung jetzt mit `withTenantScope` — jeder +Request läuft dadurch automatisch in genau einer Transaktion, nicht nur +die Store-Aufrufe, die ein Handler zufällig selbst noch mal prüft. +**Nebeneffekt (bewusst, positiv):** ein Handler mit mehreren +Schreibzugriffen ist jetzt atomar — schlägt ein späterer Schritt fehl, +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 +`tenant_isolation`): `antrag`, `registereintrag`, `abteilung`, +`werkzeug_sperre`, `genehmiger_rolle`, `freigabe_regel`, +`loeschfrist_einstellung` (direkte `account_id`-Prüfung), `werkzeug` +(nullable — `NULL` = zentraler Katalog, für alle lesbar, nur Betreiber +darf `NULL`-Zeilen schreiben), `bewertung`/`entscheidung`/ +`freigabeschritt` (kein eigenes `account_id`, Policy prüft +`antrag_id IN (SELECT id FROM antrag)` — erbt automatisch dieselbe +Grenze wie `antrag`, da diese Unterabfrage in derselben Sitzung +läuft), `nutzer_genehmiger_rolle` (analog über `genehmiger_rolle_id`). + +**Bewusst OHNE RLS** (dokumentiert in der Migration, kein Versehen): +`account` (keine `account_id`-Spalte, muss bei Registrierung +uneingeschränkt INSERT erlauben, bevor die ID bekannt ist), `app_user` +(Login/Passwort-Reset suchen per E-Mail über alle Mandanten hinweg — +die Ziel-`account_id` ist zu dem Zeitpunkt noch nicht bekannt), +`session`/`password_reset_token` (werden nur über einen +kryptographisch zufälligen, praktisch unerratbaren Token gesucht, kein +sequentiell erratbarer Schlüssel), `audit_log` (plattformweites +Protokoll ohne eigene `account_id`, wird ausschließlich vom Betreiber +gelesen, der ohnehin alle Mandanten sieht). + +**Live end-to-end verifiziert** (nicht nur Unit-Tests): Rohes SQL +gegen `deklarix_app` direkt per `psql` (Isolation, Betreiber-Bypass, +Fail-Closed ohne Kontext, INSERT-Schutz gegen fremde `account_id` — +alle vier Fälle bestätigt), danach derselbe Beweis noch mal über den +echten laufenden HTTP-Server mit `DATABASE_URL_APP` auf `deklarix_app` +gesetzt: zwei Firmen registriert, Abteilung/Antrag/Bewertung einer +Firma über die andere nicht erreichbar, zentraler Werkzeugkatalog +(NULL-`account_id`) für beide sichtbar. Dabei einen echten Bug gefunden +und behoben: `handleRegister`s `startSession`-Aufruf nutzte noch +`r.Context()` statt der lokalen, transaktionsbehafteten `ctx`-Variable +— die Session-INSERT lief dadurch auf einer anderen (Pool-)Verbindung +als der gerade erst angelegte, noch nicht committete Nutzer, was einen +Fremdschlüssel-Konflikt auslöste und die gesamte Registrierung +zurückrollte. Zwei dedizierte Tests +(`internal/store/tenant_scope_test.go`, `TestRLSAbteilungIsoliertZwischenMandanten`, +`TestRLSVerhindertInsertFuerFremdenAccount`) laufen gegen die echte +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 '';` + 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:@/deklarix?...` + in `/etc/deklarix/deklarix.env` eintragen. +3. Dienst neu starten. +`DATABASE_URL` (Migrationen, DDL-Rechte) bleibt unverändert auf der +bisherigen, privilegierten Verbindung. + +--- + ## Löschfristen je Datenklasse (2026-09-01, Migration 0020) Schließt `rules/OPEN.md`, Punkt 4. Recherche ergab: die DSGVO nennt @@ -1289,13 +1382,10 @@ journalctl -u deklarix -f - ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe „Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt. -- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)** - — die Frontend-Spezifikation fordert das explizit ("nicht in der - Anwendungslogik"), aktuell ist Isolation ausschließlich - anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS - ist ein bewusst noch nicht getroffener Architektur-Entscheid — - Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen - entstehen, die sonst nachträglich migriert werden müssten. +- ~~Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)~~ + — **Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch + ausstehend** (siehe „Row-Level-Security" weiter oben für den vollen + Stand und was auf dem Produktivserver noch manuell nachzuziehen ist). - ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person). diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index ae4a87d..200362b 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -22,7 +22,19 @@ func main() { log.Fatalf("migrate: %v", err) } - db, err := store.Open(context.Background(), databaseURL) + // Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte, + // z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet + // sich dagegen möglichst über die eingeschränkte Row-Level-Security- + // Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP, + // falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL + // zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS- + // Policies dann vollständig — kein Fehler, aber auch keine + // zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md). + appDatabaseURL := os.Getenv("DATABASE_URL_APP") + if appDatabaseURL == "" { + appDatabaseURL = databaseURL + } + db, err := store.Open(context.Background(), appDatabaseURL) if err != nil { log.Fatalf("open store: %v", err) } diff --git a/internal/store/abteilung.go b/internal/store/abteilung.go index ab7c678..b7552c0 100644 --- a/internal/store/abteilung.go +++ b/internal/store/abteilung.go @@ -20,7 +20,7 @@ type Abteilung struct { // CreateAbteilung legt eine Abteilung für einen Mandanten an. func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) { var a Abteilung - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO abteilung (account_id, name) VALUES ($1, $2) RETURNING id, account_id, name, created_at `, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt) @@ -33,7 +33,7 @@ func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Ab // ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten, // alphabetisch — als Auswahlliste für den Fragebogen. func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, name, created_at FROM abteilung WHERE account_id = $1 ORDER BY name `, accountID) @@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) // GetAbteilung liest eine Abteilung anhand ihrer ID. func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) { var a Abteilung - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1 `, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { @@ -74,7 +74,7 @@ func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) // DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt // angelegt). func (s *Store) DeleteAbteilung(ctx context.Context, id string) error { - tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id) + tag, err := s.db(ctx).Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: delete abteilung: %w", err) } diff --git a/internal/store/account.go b/internal/store/account.go index bf04e70..a793513 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -33,7 +33,7 @@ func scanAccount(row interface { // CreateAccount legt einen neuen Mandanten an. einladung_token wird von // der Datenbank per DEFAULT erzeugt (siehe Migration 0013). func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO account (name) VALUES ($1) RETURNING `+accountColumns, name, @@ -48,7 +48,7 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) // UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der // Betreiber-gestützten Anlage korrigieren). func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) { - row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name) + row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name) a, err := scanAccount(row) if errors.Is(err, pgx.ErrNoRows) { return Account{}, ErrNotFound @@ -61,7 +61,7 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er // GetAccount liest einen Mandanten anhand seiner ID. func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id) + row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id) a, err := scanAccount(row) if errors.Is(err, pgx.ErrNoRows) { return Account{}, ErrNotFound @@ -77,7 +77,7 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { // (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem // oder bereits erneuertem (damit ungültig gewordenem) Token. func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token) + row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token) a, err := scanAccount(row) if errors.Is(err, pgx.ErrNoRows) { return Account{}, ErrNotFound @@ -92,7 +92,7 @@ func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (A // durch einen neuen — der alte Link wird damit sofort ungültig (z. B. // wenn er versehentlich außerhalb des Unternehmens geteilt wurde). func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error { - tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken) + tag, err := s.db(ctx).Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken) if err != nil { return fmt.Errorf("store: regenerate einladung token: %w", err) } @@ -105,7 +105,7 @@ func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToke // ListAccounts liefert alle Mandanten, neueste zuerst — für den // Admin-Bereich (Accounts-Verwaltung). func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { - rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`) + rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`) if err != nil { return nil, fmt.Errorf("store: list accounts: %w", err) } diff --git a/internal/store/antrag.go b/internal/store/antrag.go index 2f0baf2..13febc7 100644 --- a/internal/store/antrag.go +++ b/internal/store/antrag.go @@ -46,7 +46,7 @@ func scanAntrag(row interface { // CreateAntrag legt einen neuen Antrag im Status "entwurf" an. func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel) VALUES ($1, $2, $3, $4) RETURNING `+antragColumns, @@ -62,7 +62,7 @@ func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID str // GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung, // das ist Sache des Aufrufers (siehe Antrag.AccountID). func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id) + row := s.db(ctx).QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id) a, err := scanAntrag(row) if errors.Is(err, pgx.ErrNoRows) { return Antrag{}, ErrNotFound @@ -78,7 +78,7 @@ func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) { // ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den // Status hier nicht). func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` UPDATE antrag SET titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now() WHERE id = $1 @@ -99,7 +99,7 @@ func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, // -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT // append-only — der Lebenszyklus ist eine normale Zustandsänderung. func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error { - tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status) + tag, err := s.db(ctx).Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status) if err != nil { return fmt.Errorf("store: set antrag status: %w", err) } @@ -114,7 +114,7 @@ func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error { // einsehen"), im Unterschied zu ListAntraegeForAccount, das alle // Anträge eines Mandanten liefert (Ebene 3, Posteingang). func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC `, erstellerUserID) if err != nil { @@ -139,7 +139,7 @@ func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) // ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste // zuerst. func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC `, accountID) if err != nil { diff --git a/internal/store/auditlog.go b/internal/store/auditlog.go index 1b285a5..748bb48 100644 --- a/internal/store/auditlog.go +++ b/internal/store/auditlog.go @@ -22,7 +22,7 @@ type AuditEntry struct { // CreateAuditEntry protokolliert eine Admin-Aktion. func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) { var e AuditEntry - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details) VALUES ($1, $2, $3, $4, $5) RETURNING id, actor_user_id, action, target_type, target_id, details, created_at @@ -37,7 +37,7 @@ func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targe // ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst. func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, actor_user_id, action, target_type, target_id, details, created_at FROM audit_log ORDER BY created_at DESC LIMIT $1 `, limit) diff --git a/internal/store/bewertung.go b/internal/store/bewertung.go index 06178b3..3dca89b 100644 --- a/internal/store/bewertung.go +++ b/internal/store/bewertung.go @@ -104,7 +104,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err) } - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO bewertung ( antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung, verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version @@ -124,7 +124,7 @@ func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertu // eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung // existiert. func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` SELECT `+bewertungColumns+` FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1 `, antragID) diff --git a/internal/store/entscheidung.go b/internal/store/entscheidung.go index 26a9ad1..e395173 100644 --- a/internal/store/entscheidung.go +++ b/internal/store/entscheidung.go @@ -78,7 +78,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E } } - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO entscheidung ( antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -96,7 +96,7 @@ func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (E // Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine // Entscheidung existiert. func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` SELECT `+entscheidungColumns+` FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1 `, antragID) @@ -118,7 +118,7 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str // erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug // zum Mandanten läuft über den zugehörigen antrag. func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung, e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at FROM entscheidung e diff --git a/internal/store/freigabe.go b/internal/store/freigabe.go index 78c1e03..de8ae81 100644 --- a/internal/store/freigabe.go +++ b/internal/store/freigabe.go @@ -28,7 +28,7 @@ type GenehmigerRolle struct { // Bedingung, das bleibt Sache von freigabe_regel. func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) { var g GenehmigerRolle - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3) RETURNING id, account_id, name, beschreibung, created_at `, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt) @@ -41,7 +41,7 @@ func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, besc // GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID. func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) { var g GenehmigerRolle - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1 `, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { @@ -56,7 +56,7 @@ func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRo // ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines // Mandanten. func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE account_id = $1 ORDER BY name `, accountID) @@ -82,7 +82,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st // entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein // stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend). func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error { - tag, err := s.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id) + tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: delete genehmiger rolle: %w", err) } @@ -94,7 +94,7 @@ func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error { // AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu. func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error { - _, err := s.Pool.Exec(ctx, ` + _, err := s.db(ctx).Exec(ctx, ` INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2) ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING `, userID, genehmigerRolleID) @@ -106,7 +106,7 @@ func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmiger // RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle. func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error { - _, err := s.Pool.Exec(ctx, ` + _, err := s.db(ctx).Exec(ctx, ` DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2 `, userID, genehmigerRolleID) if err != nil { @@ -118,7 +118,7 @@ func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmi // ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer // Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht. func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT gr.id, gr.account_id, gr.name, gr.created_at FROM genehmiger_rolle gr JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id @@ -151,7 +151,7 @@ type GenehmigerMitglied struct { // ListNutzerForGenehmigerRolle liefert alle Personen mit einer // bestimmten Genehmiger-Rolle. func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT u.id, u.email FROM app_user u JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id @@ -189,7 +189,7 @@ type FreigabeRegel struct { func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) { var f FreigabeRegel - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id) VALUES ($1, $2, $3, $4) RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at @@ -203,7 +203,7 @@ func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp } func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at `, accountID) @@ -224,7 +224,7 @@ func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID stri } func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error { - tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id) + tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: delete freigabe regel: %w", err) } @@ -238,7 +238,7 @@ func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error { // beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen. func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) { var f FreigabeRegel - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at FROM freigabe_regel WHERE id = $1 `, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt) @@ -269,7 +269,7 @@ type Freigabeschritt struct { func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) { var f Freigabeschritt - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2) RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at `, antragID, genehmigerRolleID).Scan( @@ -283,7 +283,7 @@ func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerR func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) { var f Freigabeschritt - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at FROM freigabeschritt WHERE id = $1 `, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt) @@ -297,7 +297,7 @@ func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschr } func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at `, antragID) @@ -321,7 +321,7 @@ func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID stri // Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person // innehat — Grundlage für "Meine Freigaben". func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at FROM freigabeschritt f JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id @@ -351,7 +351,7 @@ func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, user // generischer Fehler statt eines Sentinels). func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) { var f Freigabeschritt - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` UPDATE freigabeschritt SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4 WHERE id = $1 AND status = 'ausstehend' @@ -375,7 +375,7 @@ func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entsc // gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen // hängen zu bleiben. func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error { - _, err := s.Pool.Exec(ctx, ` + _, err := s.db(ctx).Exec(ctx, ` UPDATE freigabeschritt SET status = 'abgelehnt', entschieden_am = now(), kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.' diff --git a/internal/store/loeschfrist.go b/internal/store/loeschfrist.go index 2384b74..771bdae 100644 --- a/internal/store/loeschfrist.go +++ b/internal/store/loeschfrist.go @@ -29,7 +29,7 @@ type LoeschfristEinstellung struct { // Datenklasse (UNIQUE-Constraint). func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) { var e LoeschfristEinstellung - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage) VALUES ($1, $2, $3) ON CONFLICT (account_id, datenklasse_id) @@ -45,7 +45,7 @@ func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, dat // ListLoeschfristEinstellungenForAccount liefert alle konfigurierten // Fristen eines Mandanten. func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, datenklasse_id, max_tage, updated_at FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id `, accountID) diff --git a/internal/store/migrations/0021_row_level_security.down.sql b/internal/store/migrations/0021_row_level_security.down.sql new file mode 100644 index 0000000..0b56c26 --- /dev/null +++ b/internal/store/migrations/0021_row_level_security.down.sql @@ -0,0 +1,40 @@ +ALTER TABLE antrag DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON antrag; + +ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON registereintrag; + +ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON abteilung; + +ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre; + +ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle; + +ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel; + +ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung; + +ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON werkzeug; + +ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON bewertung; + +ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON entscheidung; + +ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt; + +ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle; + +REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app; +REVOKE USAGE ON SCHEMA public FROM deklarix_app; +DROP ROLE IF EXISTS deklarix_app; diff --git a/internal/store/migrations/0021_row_level_security.up.sql b/internal/store/migrations/0021_row_level_security.up.sql new file mode 100644 index 0000000..71de58b --- /dev/null +++ b/internal/store/migrations/0021_row_level_security.up.sql @@ -0,0 +1,164 @@ +-- 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. diff --git a/internal/store/password_reset.go b/internal/store/password_reset.go index 3d0d44b..8a96902 100644 --- a/internal/store/password_reset.go +++ b/internal/store/password_reset.go @@ -32,7 +32,7 @@ type PasswordResetToken struct { // sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst. func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) { var t PasswordResetToken - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO password_reset_token (user_id, token, expires_at) VALUES ($1, $2, $3) RETURNING id, user_id, token, expires_at, used_at, created_at @@ -49,7 +49,7 @@ func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token stri // noch nicht abgelaufen und noch nicht verwendet ist. func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) { var t PasswordResetToken - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` SELECT id, user_id, token, expires_at, used_at, created_at FROM password_reset_token WHERE token = $1 AND used_at IS NULL AND expires_at > now() @@ -66,7 +66,7 @@ func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (P // MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe // Link kein zweites Mal ein Passwort setzen kann. func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error { - tag, err := s.Pool.Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id) + tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: mark password reset token used: %w", err) } diff --git a/internal/store/registereintrag.go b/internal/store/registereintrag.go index 528dc8d..ac7a12a 100644 --- a/internal/store/registereintrag.go +++ b/internal/store/registereintrag.go @@ -64,7 +64,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp if in.Auflagen == nil { in.Auflagen = []string{} } - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO registereintrag ( account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug, datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis @@ -83,7 +83,7 @@ func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInp // ListRegistereintraegeForAccount liefert alle Registereinträge eines // Mandanten, neueste zuerst. func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC `, accountID) if err != nil { diff --git a/internal/store/session.go b/internal/store/session.go index 71654b9..985c81c 100644 --- a/internal/store/session.go +++ b/internal/store/session.go @@ -36,7 +36,7 @@ func scanSession(row interface { // CreateSession speichert eine neue Sitzung. token muss bereits ein // kryptographisch zufälliges Geheimnis sein (siehe internal/auth). func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO session (token, user_id, expires_at) VALUES ($1, $2, $3) RETURNING `+sessionColumns, @@ -55,7 +55,7 @@ func (s *Store) CreateSession(ctx context.Context, token, userID string, expires // bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst // hat — für den sichtbaren Hinweis-Banner und das Audit-Log. func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at) VALUES ($1, $2, $3, $4) RETURNING `+sessionColumns, @@ -74,7 +74,7 @@ func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, im // der Aufrufer über ExpiresAt (siehe internal/auth), damit die // Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht. func (s *Store) GetSession(ctx context.Context, token string) (Session, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token) + row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token) sess, err := scanSession(row) if errors.Is(err, pgx.ErrNoRows) { return Session{}, ErrNotFound @@ -87,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) { // DeleteSession beendet eine Sitzung (Logout). func (s *Store) DeleteSession(ctx context.Context, token string) error { - if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil { + if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil { return fmt.Errorf("store: delete session: %w", err) } return nil diff --git a/internal/store/tenant_scope.go b/internal/store/tenant_scope.go new file mode 100644 index 0000000..5a85b5a --- /dev/null +++ b/internal/store/tenant_scope.go @@ -0,0 +1,104 @@ +// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief +// bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID, +// siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen +// WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt. +// WithTenantScope öffnet für die Dauer eines Requests eine Transaktion +// und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config +// mit Parameterbindung statt String-Interpolation — SQL-Injection-frei +// und automatisch auf die Transaktion begrenzt, kein manuelles Zurück- +// setzen nötig): +// +// - app.account_id — der Mandant, für den dieser Request angemeldet ist +// - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten) +// +// Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit +// echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese +// beiden Variablen auswerten. Bewusst NICHT auf account/app_user/ +// session/password_reset_token (siehe Migration 0021 für die Begründung +// — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail). +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt — +// db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool, +// z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines +// Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede +// einzelne Store-Methode das selbst unterscheiden müsste. +type pgxIface interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +type tenantTxKey struct{} + +// db liefert die für ctx passende Ausführungsschnittstelle: die aktive +// Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool +// direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese +// Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests +// der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in +// tenant_scope_test.go für den tatsächlichen Isolationsnachweis). +func (s *Store) db(ctx context.Context) pgxIface { + if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok { + return tx + } + return s.Pool +} + +// WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs- +// variablen für RLS setzt. accountID kann leer sein (z. B. während der +// Registrierung, bevor der neue Account existiert) — SetTenantScope +// erlaubt, die Variable mitten in derselben Transaktion nachträglich zu +// setzen, sobald die ID bekannt ist. +func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error { + tx, err := s.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin tenant scope: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit + + scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx) + if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil { + return err + } + if err := fn(scopedCtx); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("store: commit tenant scope: %w", err) + } + return nil +} + +// SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer +// bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue +// Firma erst mitten im Request entsteht (die account_id ist vorher +// nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate). +// Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne +// Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen, +// falls das je außerhalb eines Handlers genutzt wird. +func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error { + tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx) + if !ok { + return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen") + } + return setTenantSessionVars(ctx, tx, accountID, isBetreiber) +} + +func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error { + betreiberFlag := "false" + if isBetreiber { + betreiberFlag = "true" + } + if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil { + return fmt.Errorf("store: set tenant scope: %w", err) + } + return nil +} diff --git a/internal/store/tenant_scope_test.go b/internal/store/tenant_scope_test.go new file mode 100644 index 0000000..e279152 --- /dev/null +++ b/internal/store/tenant_scope_test.go @@ -0,0 +1,152 @@ +// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass +// die Store-Methoden mit einer Transaktion statt dem Pool funktionieren +// (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass +// RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser +// umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen +// DATABASE_URL (Superuser, für Migrationen), sondern gegen +// DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die +// die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie +// übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup, +// siehe Migration 0021 und CLAUDE.md). +package store_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/netcell-it/deklarix/internal/store" +) + +// openRestrictedTestStore öffnet eine zweite Verbindung über +// DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und +// Fixture-Aufbau laufen weiterhin über die normale, privilegierte +// openTestStore-Verbindung. +func openRestrictedTestStore(t *testing.T) *store.Store { + t.Helper() + url := os.Getenv("DATABASE_URL_APP") + if url == "" { + t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)") + } + s, err := store.Open(context.Background(), url) + if err != nil { + t.Fatalf("Open (restricted): %v", err) + } + t.Cleanup(s.Close) + return s +} + +func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) { + privileged := openTestStore(t) + restricted := openRestrictedTestStore(t) + ctx := context.Background() + + accA := testAccountID(t, privileged) + accB := testAccountID(t, privileged) + abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A") + if err != nil { + t.Fatalf("CreateAbteilung A: %v", err) + } + if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil { + t.Fatalf("CreateAbteilung B: %v", err) + } + + // Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch + // wenn wir explizit nach IDs von B fragen würden — hier geprüft über + // die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich + // noch mal (redundant) accountID=accA übergibt; der Beweis liegt in + // TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo + // die Anwendungsschicht bewusst "falsch" fragt. + err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error { + liste, err := restricted.ListAbteilungenForAccount(scoped, accA) + if err != nil { + return err + } + if len(liste) != 1 || liste[0].Name != "Abteilung-A" { + t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste) + } + return nil + }) + if err != nil { + t.Fatalf("WithTenantScope A: %v", err) + } + + // Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B) + // fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre + // ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen, + // weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt. + err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error { + liste, err := restricted.ListAbteilungenForAccount(scoped, accB) + if err != nil { + return err + } + if len(liste) != 0 { + t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste)) + } + return nil + }) + if err != nil { + t.Fatalf("WithTenantScope A->B: %v", err) + } + + // GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS + // versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die + // AccountID selbst vergleicht. + err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error { + _, err := restricted.GetAbteilung(scoped, abtA.ID) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err) + } + return nil + }) + if err != nil { + t.Fatalf("WithTenantScope B: %v", err) + } + + // Betreiber-Kontext sieht beide. + err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error { + listeA, err := restricted.ListAbteilungenForAccount(scoped, accA) + if err != nil { + return err + } + listeB, err := restricted.ListAbteilungenForAccount(scoped, accB) + if err != nil { + return err + } + if len(listeA) != 1 || len(listeB) != 1 { + t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB) + } + return nil + }) + if err != nil { + t.Fatalf("WithTenantScope Betreiber: %v", err) + } + + // Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion, + // keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle". + liste, err := restricted.ListAbteilungenForAccount(ctx, accA) + if err != nil { + t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err) + } + if len(liste) != 0 { + t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste) + } +} + +func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) { + privileged := openTestStore(t) + restricted := openRestrictedTestStore(t) + ctx := context.Background() + + accA := testAccountID(t, privileged) + accB := testAccountID(t, privileged) + + err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error { + _, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust") + return err + }) + if err == nil { + t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt") + } +} diff --git a/internal/store/user.go b/internal/store/user.go index 218198f..6190ffc 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -43,7 +43,7 @@ func scanUser(row interface { // passwordHash muss bereits gehasht sein (siehe internal/auth) — store // speichert nur, es hasht nicht selbst. func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) { - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO app_user (account_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING `+userColumns, @@ -59,7 +59,7 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, // GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse. // Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler). func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email) + row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email) u, err := scanUser(row) if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrNotFound @@ -72,7 +72,7 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) // GetUser liest einen Nutzer anhand seiner ID. func (s *Store) GetUser(ctx context.Context, id string) (User, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id) + row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id) u, err := scanUser(row) if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrNotFound @@ -86,7 +86,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) { // ListUsersForAccount liefert alle Logins eines Mandanten — für den // Admin-Bereich (Account-Detailansicht). func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at `, accountID) if err != nil { @@ -112,7 +112,7 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us // Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei // CreateUser. func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error { - tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash) + tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash) if err != nil { return fmt.Errorf("store: set user password: %w", err) } @@ -127,7 +127,7 @@ func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) er // als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen // nachvollziehbar — deshalb (de-)aktivieren statt löschen. func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error { - tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active) + tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active) if err != nil { return fmt.Errorf("store: set user active: %w", err) } diff --git a/internal/store/werkzeug.go b/internal/store/werkzeug.go index ad1d247..7374d81 100644 --- a/internal/store/werkzeug.go +++ b/internal/store/werkzeug.go @@ -112,7 +112,7 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) { // CreateWerkzeug legt einen Katalogeintrag an. func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) { normalizeWerkzeugSlices(&in) - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` INSERT INTO werkzeug ( account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url, training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren, @@ -137,7 +137,7 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, // friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein. func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) { normalizeWerkzeugSlices(&in) - row := s.Pool.QueryRow(ctx, ` + row := s.db(ctx).QueryRow(ctx, ` UPDATE werkzeug SET name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6, training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10, @@ -161,7 +161,7 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) // GetWerkzeug liest einen Katalogeintrag anhand seiner ID. func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) { - row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id) + row := s.db(ctx).QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id) w, err := scanWerkzeug(row) if errors.Is(err, pgx.ErrNoRows) { return Werkzeug{}, ErrNotFound @@ -174,7 +174,7 @@ func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) { // DeleteWerkzeug entfernt einen Katalogeintrag. func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error { - tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id) + tag, err := s.db(ctx).Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id) if err != nil { return fmt.Errorf("store: delete werkzeug: %w", err) } @@ -188,7 +188,7 @@ func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error { // Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt // hat, plus seine eigenen mandantenspezifischen Ergänzungen. func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT `+werkzeugColumns+` FROM werkzeug w WHERE (w.account_id IS NULL AND NOT EXISTS ( SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1 @@ -218,7 +218,7 @@ func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ( // (account_id IS NULL) — für den Admin-Bereich, unabhängig von // Mandanten-Sperrungen. func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name `) if err != nil { @@ -248,7 +248,7 @@ func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) { func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) { var count int var lastUpdate time.Time - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug `).Scan(&count, &lastUpdate) if err != nil { @@ -271,7 +271,7 @@ type WerkzeugSperre struct { // CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten. func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) { var sp WerkzeugSperre - err := s.Pool.QueryRow(ctx, ` + err := s.db(ctx).QueryRow(ctx, ` INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund) VALUES ($1, $2, $3) RETURNING id, account_id, werkzeug_id, grund, gesperrt_am @@ -284,7 +284,7 @@ func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, // DeleteWerkzeugSperre hebt eine Sperrung wieder auf. func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error { - tag, err := s.Pool.Exec(ctx, ` + tag, err := s.db(ctx).Exec(ctx, ` DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2 `, accountID, werkzeugID) if err != nil { @@ -298,7 +298,7 @@ func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID // ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten. func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) { - rows, err := s.Pool.Query(ctx, ` + rows, err := s.db(ctx).Query(ctx, ` SELECT id, account_id, werkzeug_id, grund, gesperrt_am FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC `, accountID) diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index 1641e6d..5761ef5 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -1,6 +1,7 @@ package web import ( + "context" "net/http" "time" @@ -59,29 +60,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } - ctx := r.Context() - acc, err := s.store.CreateAccount(ctx, accountName) + // Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht + // ja gerade erst) — WithTenantScope startet die RLS-Transaktion + // zunächst ohne Mandant, SetTenantScope trägt die neue ID nach, + // sobald CreateAccount sie liefert. Ohne das würden die anschließenden + // Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide + // RLS-geschützt) von der Policy abgelehnt. + var errMsg string + err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error { + acc, err := s.store.CreateAccount(ctx, accountName) + if err != nil { + errMsg = "Konto konnte nicht angelegt werden" + return err + } + if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil { + errMsg = "Interner Fehler" + return err + } + user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin") + if err != nil { + errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben" + return err + } + if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil { + errMsg = "Genehmiger-Rollen konnten nicht angelegt werden" + return err + } + if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil { + errMsg = "Löschfristen konnten nicht angelegt werden" + return err + } + // r.WithContext(ctx): startSession muss in DERSELBEN Transaktion + // laufen wie CreateUser darüber — sonst sieht die Session-Zeile + // (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx + // wäre) den gerade erst angelegten, noch nicht committeten + // Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl. + if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil { + errMsg = "Sitzung konnte nicht gestartet werden" + return err + } + return nil + }) if err != nil { - s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"}) - return - } - user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin") - if err != nil { - s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}) - return - } - - if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil { - s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"}) - return - } - if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil { - s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Löschfristen konnten nicht angelegt werden"}) - return - } - - if err := s.startSession(w, r, user.ID); err != nil { - s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"}) + if errMsg == "" { + errMsg = "Registrierung fehlgeschlagen" + } + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg}) return } http.Redirect(w, r, "/", http.StatusSeeOther) diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 5c77932..d8421d0 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -63,6 +63,24 @@ func withAuthContext(r *http.Request, user store.User, impersonator string) *htt return r.WithContext(ctx) } +// withTenantScope führt next innerhalb einer Postgres-Transaktion aus, +// die die Sitzungsvariablen für Row-Level-Security setzt (siehe +// store.WithTenantScope) — jede require*-Middleware ruft das anstelle +// eines direkten next(w, r) auf, damit JEDE Store-Anfrage innerhalb +// dieses Requests automatisch auf den angemeldeten Mandanten begrenzt +// ist, nicht nur die, die ein Handler zufällig selbst noch mal prüft. +// Ein Fehler hier ist ein Infrastrukturproblem (z. B. DB down), kein +// normaler Handler-Fehlerpfad — 500 statt eines spezifischeren Codes. +func (s *Server) withTenantScope(w http.ResponseWriter, r *http.Request, user store.User, next http.HandlerFunc) { + err := s.store.WithTenantScope(r.Context(), user.AccountID, user.Role == "betreiber", func(ctx context.Context) error { + next(w, r.WithContext(ctx)) + return nil + }) + if err != nil { + http.Error(w, "Datenbankfehler: "+err.Error(), http.StatusInternalServerError) + } +} + // requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht // es zurück zu /login (eine echte Navigation, kein htmx-Fragment). func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { @@ -72,7 +90,7 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - next(w, withAuthContext(r, user, impersonator)) + s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next) } } @@ -87,7 +105,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } - next(w, withAuthContext(r, user, impersonator)) + s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next) } } @@ -108,7 +126,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht gefunden", http.StatusNotFound) return } - next(w, withAuthContext(r, user, impersonator)) + s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next) } } @@ -138,7 +156,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht gefunden", http.StatusNotFound) return } - next(w, withAuthContext(r, user, impersonator)) + s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next) } } @@ -156,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "nicht gefunden", http.StatusNotFound) return } - next(w, withAuthContext(r, user, impersonator)) + s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next) } } diff --git a/internal/web/server.go b/internal/web/server.go index 95ffd65..2f636e4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -29,6 +29,15 @@ var staticFS embed.FS // komplette *store.Store. *store.Store erfüllt sie; Tests injizieren // einen Fake statt eine echte Datenbank zu brauchen. type Store interface { + // WithTenantScope/SetTenantScope: siehe internal/store/tenant_scope.go + // (Row-Level-Security-Sitzungskontext). fakeStore implementiert beide + // als reinen Passthrough — Tests laufen ohne echtes Postgres/RLS, + // die Isolation wird dort weiterhin (wie bisher) auf Anwendungsebene + // geprüft; die tatsächliche RLS-Durchsetzung hat eigene Tests gegen + // echtes Postgres, siehe internal/store/tenant_scope_test.go. + WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error + SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error + CreateAccount(ctx context.Context, name string) (store.Account, error) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index f6e2714..d623375 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -70,6 +70,17 @@ func newFakeStore() *fakeStore { } } +// WithTenantScope/SetTenantScope sind im fakeStore reine Passthroughs — +// fakeStore hat kein RLS-Äquivalent, Tests prüfen Isolation weiterhin +// wie bisher auf Anwendungsebene (AccountID-Vergleich in den Handlern). +func (f *fakeStore) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error { + return fn(ctx) +} + +func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error { + return nil +} + func (f *fakeStore) newID() string { f.nextID++ return fmt.Sprintf("id-%d", f.nextID)