feat: Row-Level-Security für Mandantenisolation auf DB-Ebene

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 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-01 09:22:20 +02:00
parent 0fe29f8c80
commit fe28278615
23 changed files with 727 additions and 103 deletions

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)