Files
deklarix/internal/web/middleware.go
noroot fe28278615 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>
2026-09-01 09:22:20 +02:00

224 lines
8.5 KiB
Go

package web
import (
"context"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
const sessionCookieName = "deklarix_session"
type contextKey int
const (
userContextKey contextKey = iota
impersonatorContextKey
)
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
// sonst leer.
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return store.User{}, "", false
}
sess, err := s.store.GetSession(r.Context(), cookie.Value)
if err != nil {
return store.User{}, "", false
}
if time.Now().After(sess.ExpiresAt) {
return store.User{}, "", false
}
user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil || !user.Active {
return store.User{}, "", false
}
impersonator := ""
if sess.ImpersonatedByUserID != nil {
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
impersonator = betreiber.Email
}
}
return user, impersonator, true
}
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
ctx := context.WithValue(r.Context(), userContextKey, user)
if impersonator != "" {
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
}
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 {
return func(w http.ResponseWriter, r *http.Request) {
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
// werden nur aus einer bereits authentifizierten Seite heraus
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
// verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "betreiber" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
// — ohne das könnte eine frisch registrierte Firma mit nur einem
// admin-Login keinen einzigen eingereichten Antrag sehen oder
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, impersonator, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll.
type navData struct {
IsBetreiber bool
IsFachebene bool
IsAdmin bool
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
role := currentUser(r).Role
return navData{
IsBetreiber: role == "betreiber",
// admin sieht die Fachebene mit, siehe requireFachebene.
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin",
Impersonator: currentImpersonator(r),
}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
// kein Laufzeitfall, den man abfangen sollte.
func currentUser(r *http.Request) store.User {
user, ok := r.Context().Value(userContextKey).(store.User)
if !ok {
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
}
return user
}
// currentImpersonator liefert die E-Mail des Betreibers, falls die
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
func currentImpersonator(r *http.Request) string {
v, _ := r.Context().Value(impersonatorContextKey).(string)
return v
}