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>
236 lines
15 KiB
Go
236 lines
15 KiB
Go
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
|
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
|
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
|
|
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
|
// Account — siehe middleware.go.
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/mail"
|
|
"github.com/netcell-it/deklarix/internal/rules"
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
|
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
|
// 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)
|
|
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
|
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
|
ListAccounts(ctx context.Context) ([]store.Account, error)
|
|
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
|
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
|
GetUser(ctx context.Context, id string) (store.User, error)
|
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
|
SetUserActive(ctx context.Context, id string, active bool) error
|
|
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
|
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
|
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
|
|
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
|
|
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
|
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
|
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
|
DeleteSession(ctx context.Context, token string) error
|
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
|
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
|
|
|
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
|
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
|
|
DeleteAbteilung(ctx context.Context, id string) error
|
|
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
|
|
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
|
|
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
|
SetAntragStatus(ctx context.Context, id, status string) error
|
|
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
|
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
|
|
|
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
|
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
|
|
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
|
|
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
|
|
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
|
|
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
|
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
|
|
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
|
|
DeleteWerkzeug(ctx context.Context, id string) error
|
|
CurrentKatalogVersion(ctx context.Context) (string, error)
|
|
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
|
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
|
|
|
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
|
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
|
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
|
|
|
|
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
|
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
|
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
|
|
|
CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error)
|
|
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
|
|
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
|
|
DeleteGenehmigerRolle(ctx context.Context, id string) error
|
|
AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
|
RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
|
ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error)
|
|
ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error)
|
|
|
|
CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error)
|
|
ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error)
|
|
GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error)
|
|
DeleteFreigabeRegel(ctx context.Context, id string) error
|
|
|
|
CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error)
|
|
GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error)
|
|
ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error)
|
|
ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error)
|
|
EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error)
|
|
KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error
|
|
}
|
|
|
|
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
|
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
|
|
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
|
|
// Stelle zusammenstellt.
|
|
type Regelwerk struct {
|
|
Datenklasse rules.DatenklasseRegelwerk
|
|
Einstufung rules.EinstufungRegelwerk
|
|
Anforderungen rules.AnforderungsRegelwerk
|
|
}
|
|
|
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
|
type Server struct {
|
|
mux *http.ServeMux
|
|
store Store
|
|
regelwerk Regelwerk
|
|
templates *template.Template
|
|
mailer mail.Mailer
|
|
}
|
|
|
|
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
|
// wird einmal beim Start geladen, nicht pro Request. mailer ist
|
|
// austauschbar (siehe mail.FakeMailer für Tests) — ohne konfigurierten
|
|
// SMTP-Server schlägt nur der tatsächliche Versand fehl (z. B.
|
|
// Passwort-Zurücksetzen), der restliche Server bleibt funktionsfähig.
|
|
func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, error) {
|
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
|
}
|
|
|
|
s := &Server{
|
|
store: st,
|
|
regelwerk: regelwerk,
|
|
templates: tmpl,
|
|
mailer: mailer,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
|
mux.HandleFunc("POST /register", s.handleRegister)
|
|
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
|
|
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
|
mux.HandleFunc("POST /login", s.handleLogin)
|
|
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
|
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
|
|
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
|
|
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
|
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
|
mux.HandleFunc("POST /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzen)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
|
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
|
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
|
|
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
|
|
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
|
|
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
|
|
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
|
|
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
|
|
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
|
|
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
|
|
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
|
|
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
|
|
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
|
|
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
|
|
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
|
|
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
|
|
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
|
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
|
|
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
|
|
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
|
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
|
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
|
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
|
|
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
|
|
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
|
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
|
|
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
|
|
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
|
mux.HandleFunc("GET /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRollenListe))
|
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRolleCreate))
|
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/loeschen", s.requireAdmin(s.handleGenehmigerRolleDelete))
|
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder", s.requireAdmin(s.handleGenehmigerRolleMitgliedHinzufuegen))
|
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder/{userID}/entfernen", s.requireAdmin(s.handleGenehmigerRolleMitgliedEntfernen))
|
|
mux.HandleFunc("GET /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelnListe))
|
|
mux.HandleFunc("POST /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelCreate))
|
|
mux.HandleFunc("POST /verwaltung/freigabe-regeln/{id}/loeschen", s.requireAdmin(s.handleFreigabeRegelDelete))
|
|
mux.HandleFunc("GET /freigaben", s.requirePage(s.handleFreigabenListe))
|
|
mux.HandleFunc("POST /freigaben/{id}/entscheiden", s.requireAPI(s.handleFreigabeEntscheiden))
|
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
|
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
|
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
|
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
|
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
|
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
|
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
|
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
|
|
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
|
|
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
|
|
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
|
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
|
s.mux = mux
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// ServeHTTP macht Server zu einem http.Handler.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|