Deklarix soll ein buchbarer Service werden — dafür muss jede Entität im
Datenmodell über das Frontend steuerbar sein, nicht nur einsehbar.
Schließt drei konkrete Lücken:
- Abteilungen (GET /verwaltung/abteilungen, anlegen/löschen) — ohne
diese Seite blieb die Abteilung-Auswahl im Antrag-Fragebogen leer
und unbenutzbar, das war ein Funktionsdefizit, kein Komfortfehler.
- Eigene Werkzeug-Sperrungen (GET /verwaltung/werkzeuge) — ein Mandant
kann einen zentralen Katalogeintrag jetzt für sich sperren/entsperren,
ohne den zentralen Katalog selbst zu verändern.
- Nutzer-Deaktivierung (POST /verwaltung/nutzer/{id}/deaktivieren bzw.
.../aktivieren, neue Spalte app_user.active, Migration 0012). Nutzer
werden nicht gelöscht (Fremdschlüssel auf antrag/entscheidung/
audit_log würden das verhindern und die Historie zerstören) —
deaktivierte Logins können sich nicht mehr anmelden und verlieren
eine laufende Sitzung sofort. Ein Admin kann sich nicht selbst
deaktivieren.
Zusätzlich: store.ListAktiveGenehmigungenForAccount als Grundlage für
die Wiedervorlage (Schritt 7, Web-Layer folgt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
166 lines
5.7 KiB
Go
166 lines
5.7 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
|
|
|
|
// 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.
|
|
func (s *Server) authenticate(r *http.Request) (store.User, 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
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
// 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, ok := s.authenticate(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// 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, ok := s.authenticate(r)
|
|
if !ok {
|
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
|
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
|
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
|
|
// Seite tatsächlich entschieden werden darf, prüft der einzelne
|
|
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). 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, ok := s.authenticate(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
|
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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",
|
|
IsFachebene: role == "verantwortlicher" || role == "pruefer",
|
|
IsAdmin: role == "admin",
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|