fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage)
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 <noreply@anthropic.com>
This commit is contained in:
24
CLAUDE.md
24
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
|
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
|
||||||
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
||||||
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
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
|
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||||
@@ -750,11 +753,20 @@ journalctl -u deklarix -f
|
|||||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||||
entstehen, die sonst nachträglich migriert werden müssten.
|
entstehen, die sonst nachträglich migriert werden müssten.
|
||||||
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
|
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||||
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
|
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||||
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
|
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||||
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
|
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
|
||||||
zwei `app_user`-Zeilen für dieselbe Person?
|
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
|
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
||||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
||||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
||||||
|
|||||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,13 @@ type fallDetailData struct {
|
|||||||
Error string
|
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
|
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
||||||
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
||||||
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
// 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.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)
|
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -147,13 +154,14 @@ func gueltigkeitFuer(einstufung string) time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
||||||
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
|
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
|
||||||
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
|
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
|
||||||
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
|
// requireFachebene (404, weil die Seite für sie gar nicht existieren
|
||||||
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
|
// 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) {
|
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
if user.Role != "verantwortlicher" {
|
if !hatEntscheidungsrecht(user.Role) {
|
||||||
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,16 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
|
|
||||||
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||||
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||||
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
|
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
|
||||||
// Seite tatsächlich entschieden werden darf, prüft der einzelne
|
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
|
||||||
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
|
// 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
|
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
|
||||||
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
||||||
// nicht erfährt, dass es die Seite überhaupt gibt.
|
// 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)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
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)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +154,8 @@ func navFor(r *http.Request) navData {
|
|||||||
role := currentUser(r).Role
|
role := currentUser(r).Role
|
||||||
return navData{
|
return navData{
|
||||||
IsBetreiber: role == "betreiber",
|
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",
|
IsAdmin: role == "admin",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user