From 3ec57f2786db19dc11bd7393caf52d7cb8ff113f Mon Sep 17 00:00:00 2001 From: noroot Date: Sat, 29 Aug 2026 17:58:06 +0200 Subject: [PATCH] fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat nur einen admin-Login und konnte damit bislang keinen einzigen eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur verantwortlicher/pruefer durchließ. Die Spezifikation will admin+ KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md, Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht in fachebene_handlers.go). pruefer bleibt unverändert nur lesend. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 24 +++++++--- internal/web/admin_fachebene_test.go | 72 ++++++++++++++++++++++++++++ internal/web/fachebene_handlers.go | 20 +++++--- internal/web/middleware.go | 18 +++++-- 4 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 internal/web/admin_fachebene_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 5185d0c..5c7a82c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,10 @@ Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5, Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber` ist der Nachfolger dessen, was früher (vor dem Produktwechsel) -`admin` hieß. +`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin` +bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher` +(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst +ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten. Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware je Handler), nicht als grob unterschiedene Seitenbereiche. @@ -750,11 +753,20 @@ journalctl -u deklarix -f ist ein bewusst noch nicht getroffener Architektur-Entscheid — Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen entstehen, die sonst nachträglich migriert werden müssten. -- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die - Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig - hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt - werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder - zwei `app_user`-Zeilen für dieselbe Person? +- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch + gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner + Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person). + Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3) + dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/ + Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in + `fachebene_handlers.go`). Grund: ohne das könnte eine frisch + registrierte Firma mit nur einem `admin`-Login keinen einzigen + eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für + Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer` + bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt + "sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher + bewusst auf zwei verschiedene Personen verteilen will, funktioniert das + weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen). - **Zentraler Werkzeugkatalog muss noch befüllt werden** — die Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET /betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu` diff --git a/internal/web/admin_fachebene_test.go b/internal/web/admin_fachebene_test.go new file mode 100644 index 0000000..3c6c0a5 --- /dev/null +++ b/internal/web/admin_fachebene_test.go @@ -0,0 +1,72 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin +// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md, +// Offene Punkte / requireFachebene): ein admin sieht den Posteingang +// und kann entscheiden, genau wie ein verantwortlicher. +func TestAdminSiehtPosteingang(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + _, adminCookie := seedFallImAccount(t, fs, s, "admin") + + resp := getWithCookie(t, s, adminCookie, "/faelle") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") { + t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String()) + } +} + +func TestAdminSiehtEntscheidenFormular(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, adminCookie := seedFallImAccount(t, fs, s, "admin") + + resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), `name="entscheidung"`) { + t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String()) + } +} + +func TestAdminKannEntscheiden(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, adminCookie := seedFallImAccount(t, fs, s, "admin") + + resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + antrag, err := fs.GetAntrag(context.Background(), antragID) + if err != nil { + t.Fatalf("GetAntrag: %v", err) + } + if antrag.Status != "entschieden" { + t.Errorf("Status = %q, want entschieden", antrag.Status) + } +} + +func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + for _, path := range []string{"/registereintraege", "/wiedervorlage"} { + resp := getWithCookie(t, s, adminCookie, path) + if resp.Code != http.StatusOK { + t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code) + } + } +} diff --git a/internal/web/fachebene_handlers.go b/internal/web/fachebene_handlers.go index c8be33e..d6c1cfc 100644 --- a/internal/web/fachebene_handlers.go +++ b/internal/web/fachebene_handlers.go @@ -78,6 +78,13 @@ type fallDetailData struct { Error string } +// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe +// requireFachebene: die Spezifikation will admin+verantwortlicher auf +// derselben Person, ohne dass app_user.role zwei Werte tragen kann. +func hatEntscheidungsrecht(role string) bool { + return role == "verantwortlicher" || role == "admin" +} + // vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das // System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob // eine tatsächliche Entscheidung davon abweicht (dann ist die @@ -115,7 +122,7 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) { data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)}) } } - data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil + data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID) if err == nil { @@ -147,13 +154,14 @@ func gueltigkeitFuer(einstufung string) time.Time { } // handleFallEntscheiden speichert die Entscheidung eines/einer -// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein -// Entscheidungsrecht — anders als bei requireFachebene (404, weil die -// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein -// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden. +// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer +// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei +// requireFachebene (404, weil die Seite für sie gar nicht existieren +// soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall +// existiert, nur nicht entscheiden. func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { user := currentUser(r) - if user.Role != "verantwortlicher" { + if !hatEntscheidungsrecht(user.Role) { http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden) return } diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 4f70ccf..e371088 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -91,9 +91,16 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { // 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 +// 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. @@ -104,7 +111,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - if user.Role != "verantwortlicher" && user.Role != "pruefer" { + if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" { http.Error(w, "nicht gefunden", http.StatusNotFound) return } @@ -147,7 +154,8 @@ func navFor(r *http.Request) navData { role := currentUser(r).Role return navData{ IsBetreiber: role == "betreiber", - IsFachebene: role == "verantwortlicher" || role == "pruefer", + // admin sieht die Fachebene mit, siehe requireFachebene. + IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin", IsAdmin: role == "admin", } }