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>
224 lines
8.5 KiB
Go
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
|
|
}
|