feat: konfigurierbaren Freigabe-Workflow mit Genehmiger-Rollen einführen

Mandanten können jetzt selbst festlegen, wer bei welcher abgeleiteten
Bedingung (Anforderung/Einstufung/Datenklasse) zusätzlich zur
Fachebene-Entscheidung zustimmen muss (z. B. Datenschutzbeauftragter,
Geschäftsführung) - Genehmiger-Rollen sind orthogonal zu den fünf
bestehenden Zugriffsrollen. Alle ausgelösten Rollen müssen zustimmen,
eine Ablehnung kippt kaskadierend den gesamten Antrag. Mandanten ohne
Freigabe-Regeln (aktuell alle) sind unverändert vom alten Direktpfad
betroffen, siehe TestOhneFreigabeRegelnVerhaeltSichWieVorher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-31 21:42:28 +02:00
parent 9e2c7f92ff
commit f729e5ae48
16 changed files with 2107 additions and 36 deletions

View File

@@ -5,6 +5,7 @@
package web
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -89,9 +90,18 @@ type fallDetailData struct {
Entscheidung *entscheidungView
CanEntscheiden bool
WerkzeugOptionen []werkzeugOption
Freigabeschritte []freigabeschrittView
Error string
}
// freigabeschrittView zeigt den Stand eines einzelnen, zusätzlich zur
// Fachebene-Entscheidung nötigen Freigabeschritts an.
type freigabeschrittView struct {
RolleName string
Status string
Kommentar 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.
@@ -158,6 +168,24 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
data.Entscheidung = v
}
// Nicht auf Status "wartet_auf_freigabe" beschränkt: die Freigabehistorie
// bleibt auch nach vollständiger Entscheidung sichtbar (Grundregel: eine
// Entscheidung wird nie ohne vollständige Herleitung angezeigt). Für
// Anträge ohne ausgelöste Freigabe-Regel liefert die Abfrage einfach
// eine leere Liste, das Template blendet den Abschnitt dann aus.
schritte, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
if err == nil {
for _, schritt := range schritte {
rolleName := ""
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
rolleName = rolle.Name
}
data.Freigabeschritte = append(data.Freigabeschritte, freigabeschrittView{
RolleName: rolleName, Status: schritt.Status, Kommentar: schritt.Kommentar,
})
}
}
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -224,7 +252,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: entscheidungTyp, Begruendung: begruendung,
}
var werkzeugName string
if istGenehmigung {
zulaessig := false
for _, id := range bewertung.ZulaessigeWerkzeuge {
@@ -242,7 +269,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
werkzeugName = werkzeug.Name
in.WerkzeugID = &werkzeugID
in.WerkzeugSnapshot = &werkzeug
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
@@ -254,6 +280,39 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
// Konfigurierbarer Mehrfach-Freigabe-Workflow (siehe CLAUDE.md): eine
// Genehmigung ist nicht zwangsläufig sofort final — löst die
// Bewertung eine Freigabe-Regel des Mandanten aus (z. B.
// dsfa_erforderlich → Datenschutzbeauftragter), wartet der Antrag
// erst auf diese zusätzlichen Freigaben, bevor SetAntragStatus auf
// "entschieden" geht und ein Registereintrag entsteht.
if istGenehmigung {
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), user.AccountID)
if err != nil {
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
if rollenIDs := triggeredGenehmigerRollen(regeln, bewertung); len(rollenIDs) > 0 {
for _, rolleID := range rollenIDs {
if _, err := s.store.CreateFreigabeschritt(r.Context(), antrag.ID, rolleID); err != nil {
http.Error(w, "Freigabeschritt konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "wartet_auf_freigabe"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_wartet_auf_freigabe", "antrag", antrag.ID, entscheidungTyp); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
return
}
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
@@ -262,27 +321,8 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
// Genehmigtes, das im Register stehen müsste.
if istGenehmigung {
abteilungName := ""
if antrag.AbteilungID != nil {
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
abteilungName = abt.Name
}
}
var auflagen []string
for _, a := range bewertung.Anforderungen {
auflagen = append(auflagen, a.Beschreibung)
}
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
}); err != nil {
if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -290,3 +330,67 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
}
// registriereGenehmigung schließt eine (ggf. erst nach zusätzlichen
// Freigaben) endgültig genehmigte Entscheidung ab: Registereintrag
// anlegen (siehe CLAUDE.md — Antragstatus ist an dieser Stelle bereits
// auf "entschieden" gesetzt). Wiederverwendet von handleFallEntscheiden
// (keine zusätzliche Freigabe nötig) und handleFreigabeEntscheiden
// (letzter ausstehender Freigabeschritt wurde soeben genehmigt).
func (s *Server) registriereGenehmigung(ctx context.Context, antrag store.Antrag, entscheidung store.Entscheidung, bewertung store.Bewertung) error {
abteilungName := ""
if antrag.AbteilungID != nil {
if abt, err := s.store.GetAbteilung(ctx, *antrag.AbteilungID); err == nil {
abteilungName = abt.Name
}
}
werkzeugName := ""
if entscheidung.WerkzeugSnapshot != nil {
werkzeugName = entscheidung.WerkzeugSnapshot.Name
}
verantwortlicherEmail := ""
if u, err := s.store.GetUser(ctx, entscheidung.EntscheiderUserID); err == nil {
verantwortlicherEmail = u.Email
}
var auflagen []string
for _, a := range bewertung.Anforderungen {
auflagen = append(auflagen, a.Beschreibung)
}
_, err := s.store.CreateRegistereintrag(ctx, store.RegistereintragInput{
AccountID: antrag.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
Verantwortlicher: verantwortlicherEmail, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
})
return err
}
// triggeredGenehmigerRollen liefert die eindeutigen Genehmiger-Rollen,
// deren Freigabe-Regel durch diese Bewertung ausgelöst wird — Bedingung
// ist eine der vom Regelwerk bereits abgeleiteten Größen (Anforderungs-
// ID, Einstufung, Datenklasse), kein freier Regel-Editor.
func triggeredGenehmigerRollen(regeln []store.FreigabeRegel, bewertung store.Bewertung) []string {
var out []string
seen := map[string]bool{}
for _, regel := range regeln {
var treffer bool
switch regel.BedingungTyp {
case "anforderung":
for _, a := range bewertung.Anforderungen {
if a.ID == regel.BedingungWert {
treffer = true
break
}
}
case "einstufung":
treffer = bewertung.Einstufung == regel.BedingungWert
case "datenklasse":
treffer = bewertung.Datenklasse == regel.BedingungWert
}
if treffer && !seen[regel.GenehmigerRolleID] {
seen[regel.GenehmigerRolleID] = true
out = append(out, regel.GenehmigerRolleID)
}
}
return out
}

View File

@@ -0,0 +1,462 @@
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
// und CLAUDE.md. Drei Seitengruppen:
// - Admin (Ebene 4): Genehmiger-Rollen anlegen/Mitglieder zuweisen,
// Freigabe-Regeln definieren ("wenn Bedingung X, dann Freigabe
// durch Rolle Y nötig").
// - Jeder angemeldete Nutzer: "Meine Freigaben" — eigene ausstehende
// Freigabeschritte, unabhängig von app_user.role (eine
// Genehmiger-Rolle ist orthogonal zur Zugriffsrolle).
package web
import (
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
// ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
type genehmigerRolleItem struct {
ID string
Name string
Mitglieder []nutzerOption
}
type nutzerOption struct {
ID string
Email string
}
type genehmigerRollenListeData struct {
Title string
Nav navData
Rollen []genehmigerRolleItem
Nutzer []nutzerOption
Error string
}
func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
users, err := s.store.ListUsersForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := genehmigerRollenListeData{Title: "Genehmiger-Rollen", Nav: navFor(r)}
for _, u := range users {
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
}
for _, g := range rollen {
item := genehmigerRolleItem{ID: g.ID, Name: g.Name}
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
if err == nil {
for _, m := range mitglieder {
item.Mitglieder = append(item.Mitglieder, nutzerOption{ID: m.UserID, Email: m.Email})
}
}
data.Rollen = append(data.Rollen, item)
}
if err := s.templates.ExecuteTemplate(w, "genehmiger-rollen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name); err != nil {
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
// handleGenehmigerRolleDelete entfernt eine Genehmiger-Rolle. Solange
// noch Mitglieder zugewiesen sind oder eine Freigabe-Regel darauf
// verweist, schlägt das per Fremdschlüssel fehl — bewusst kein CASCADE,
// ein stillschweigendes Mit-Löschen wirksamer Freigabe-Regeln wäre
// überraschend und sicherheitsrelevant.
func (s *Server) handleGenehmigerRolleDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rolle, err := s.store.GetGenehmigerRolle(r.Context(), id)
if err != nil || rolle.AccountID != currentUser(r).AccountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteGenehmigerRolle(r.Context(), id); err != nil {
http.Error(w, "Genehmiger-Rolle wird noch verwendet (Mitglieder oder Freigabe-Regeln) und kann nicht gelöscht werden", http.StatusBadRequest)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
func (s *Server) handleGenehmigerRolleMitgliedHinzufuegen(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
rolleID := r.PathValue("id")
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
if err != nil || rolle.AccountID != accountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
userID := r.FormValue("user_id")
target, err := s.store.GetUser(r.Context(), userID)
if err != nil || target.AccountID != accountID {
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.AddNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
http.Error(w, "Zuweisung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
func (s *Server) handleGenehmigerRolleMitgliedEntfernen(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
rolleID := r.PathValue("id")
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
if err != nil || rolle.AccountID != accountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
userID := r.PathValue("userID")
if err := s.store.RemoveNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
http.Error(w, "Entfernen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
// ─── Admin: Freigabe-Regeln ─────────────────────────────────────────
// bedingungOption ist ein Auswahlpunkt im Freigabe-Regel-Formular —
// Value kodiert "typ:wert" (z. B. "anforderung:dsfa_erforderlich"),
// damit ein einziges <select> ohne JavaScript reicht statt zweier
// voneinander abhängiger Dropdowns.
type bedingungOption struct {
Value string
Label string
}
type bedingungGruppe struct {
Label string
Optionen []bedingungOption
}
type freigabeRegelItem struct {
ID string
BedingungLabel string
GenehmigerRolle string
}
type freigabeRegelnListeData struct {
Title string
Nav navData
Regeln []freigabeRegelItem
BedingungGruppen []bedingungGruppe
RollenOptionen []store.GenehmigerRolle
Error string
}
// bedingungGruppenAusRegelwerk baut die Freigabe-Regel-Bedingungsauswahl
// aus dem tatsächlich geladenen Regelwerk (nicht frei erfunden) — jede
// Anforderungs-/Einstufungs-/Datenklasse-ID, die das System ohnehin
// kennt, ist eine mögliche Bedingung.
func (s *Server) bedingungGruppenAusRegelwerk() []bedingungGruppe {
var anforderungen []bedingungOption
for _, a := range s.regelwerk.Anforderungen.Anforderungen {
label := a.ID
if a.Beschreibung != "" {
label = a.Beschreibung
}
anforderungen = append(anforderungen, bedingungOption{Value: "anforderung:" + a.ID, Label: label})
}
var einstufungen []bedingungOption
for _, e := range s.regelwerk.Einstufung.Stufen {
einstufungen = append(einstufungen, bedingungOption{Value: "einstufung:" + e.ID, Label: e.ID})
}
var datenklassen []bedingungOption
for _, d := range s.regelwerk.Datenklasse.Stufen {
datenklassen = append(datenklassen, bedingungOption{Value: "datenklasse:" + d.ID, Label: d.ID})
}
return []bedingungGruppe{
{Label: "Anforderung", Optionen: anforderungen},
{Label: "Einstufung", Optionen: einstufungen},
{Label: "Datenklasse", Optionen: datenklassen},
}
}
func (s *Server) handleFreigabeRegelnListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
rollenName := map[string]string{}
for _, g := range rollen {
rollenName[g.ID] = g.Name
}
data := freigabeRegelnListeData{
Title: "Freigabe-Regeln", Nav: navFor(r),
BedingungGruppen: s.bedingungGruppenAusRegelwerk(), RollenOptionen: rollen,
}
for _, regel := range regeln {
data.Regeln = append(data.Regeln, freigabeRegelItem{
ID: regel.ID,
BedingungLabel: bedingungTypLabel(regel.BedingungTyp) + ": " + regel.BedingungWert,
GenehmigerRolle: rollenName[regel.GenehmigerRolleID],
})
}
if err := s.templates.ExecuteTemplate(w, "freigabe-regeln-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func bedingungTypLabel(typ string) string {
switch typ {
case "anforderung":
return "Anforderung"
case "einstufung":
return "Einstufung"
case "datenklasse":
return "Datenklasse"
default:
return typ
}
}
func (s *Server) handleFreigabeRegelCreate(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
teile := strings.SplitN(r.FormValue("bedingung"), ":", 2)
rolleID := r.FormValue("genehmiger_rolle_id")
if len(teile) != 2 || teile[0] == "" || teile[1] == "" || rolleID == "" {
http.Error(w, "Bedingung und Genehmiger-Rolle sind Pflicht", http.StatusBadRequest)
return
}
typ, wert := teile[0], teile[1]
if typ != "anforderung" && typ != "einstufung" && typ != "datenklasse" {
http.Error(w, "ungültiger Bedingungstyp", http.StatusBadRequest)
return
}
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
if err != nil || rolle.AccountID != accountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
if _, err := s.store.CreateFreigabeRegel(r.Context(), accountID, typ, wert, rolleID); err != nil {
http.Error(w, "Freigabe-Regel konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
}
func (s *Server) handleFreigabeRegelDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
regel, err := s.store.GetFreigabeRegel(r.Context(), id)
if err != nil || regel.AccountID != currentUser(r).AccountID {
http.Error(w, "Freigabe-Regel nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteFreigabeRegel(r.Context(), id); err != nil {
http.Error(w, "Freigabe-Regel konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
}
// ─── Meine Freigaben (jeder angemeldete Nutzer) ─────────────────────
type freigabeItem struct {
ID string
AntragID string
AntragTitel string
RolleName string
CreatedAt string
}
type freigabenListeData struct {
Title string
Nav navData
Freigaben []freigabeItem
}
// handleFreigabenListe zeigt die eigenen ausstehenden Freigabeschritte
// — unabhängig von app_user.role, denn eine Genehmiger-Rolle ist eine
// orthogonale Freigabe-Funktion, keine Zugriffsrolle.
func (s *Server) handleFreigabenListe(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
schritte, err := s.store.ListAusstehendeFreigabeschritteForUser(r.Context(), user.ID)
if err != nil {
http.Error(w, "Freigaben konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := freigabenListeData{Title: "Meine Freigaben", Nav: navFor(r)}
for _, schritt := range schritte {
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
if err != nil {
continue
}
rolleName := ""
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
rolleName = rolle.Name
}
data.Freigaben = append(data.Freigaben, freigabeItem{
ID: schritt.ID, AntragID: antrag.ID, AntragTitel: antrag.Titel,
RolleName: rolleName, CreatedAt: schritt.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "freigaben-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleFreigabeEntscheiden trägt die Entscheidung einer Person zu
// einem einzelnen Freigabeschritt ein. Genehmigt sie den letzten noch
// ausstehenden Schritt eines Antrags, wird die ursprüngliche Genehmigung
// jetzt final abgeschlossen (Registereintrag). Lehnt sie ab, kippt das
// den gesamten Antrag — alle anderen offenen Schritte desselben Antrags
// werden automatisch mit abgelehnt (KaskadiereAblehnung), und eine neue
// Entscheidung "abgelehnt" übersteuert die ursprüngliche Genehmigung
// (entscheidung ist append-only, siehe CLAUDE.md).
func (s *Server) handleFreigabeEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := r.PathValue("id")
schritt, err := s.store.GetFreigabeschritt(r.Context(), id)
if err != nil {
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
return
}
rollen, err := s.store.ListGenehmigerRollenForUser(r.Context(), user.ID)
if err != nil {
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
hatRolle := false
for _, rl := range rollen {
if rl.ID == schritt.GenehmigerRolleID {
hatRolle = true
break
}
}
if !hatRolle {
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
return
}
if schritt.Status != "ausstehend" {
http.Error(w, "dieser Freigabeschritt wurde bereits entschieden", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
entscheidungTyp := r.FormValue("entscheidung")
kommentar := r.FormValue("kommentar")
if entscheidungTyp != "genehmigt" && entscheidungTyp != "abgelehnt" {
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
return
}
if _, err := s.store.EntscheideFreigabeschritt(r.Context(), id, entscheidungTyp, user.ID, kommentar); err != nil {
http.Error(w, "Freigabeschritt konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
if err != nil {
http.Error(w, "Antrag nicht gefunden", http.StatusInternalServerError)
return
}
rolle, _ := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID)
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "freigabeschritt_entschieden", "antrag", antrag.ID, rolle.Name+": "+entscheidungTyp); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Bewertung nicht gefunden", http.StatusInternalServerError)
return
}
if entscheidungTyp == "abgelehnt" {
if err := s.store.KaskadiereAblehnung(r.Context(), antrag.ID, id); err != nil {
http.Error(w, "Kaskadierung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
begruendung := "Freigabe durch " + rolle.Name + " wurde verweigert."
if kommentar != "" {
begruendung += " Kommentar: " + kommentar
}
if _, err := s.store.CreateEntscheidung(r.Context(), store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: "abgelehnt", Begruendung: begruendung,
}); err != nil {
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
alle, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Freigabeschritte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
vollstaendig := true
for _, fs := range alle {
if fs.Status != "genehmigt" {
vollstaendig = false
break
}
}
if vollstaendig {
letzteEntscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Entscheidung nicht gefunden", http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.registriereGenehmigung(r.Context(), antrag, letzteEntscheidung, bewertung); err != nil {
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
http.Redirect(w, r, "/freigaben", http.StatusSeeOther)
}

View File

@@ -0,0 +1,283 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/web"
)
// besondereKategorieAntragForm loest ueber b2=ja die Datenklasse
// besondere_kategorie aus, die laut rules/anforderungen.yaml die
// Anforderung dsfa_erforderlich nach sich zieht — Grundlage fuer die
// Freigabe-Workflow-Tests.
func besondereKategorieAntragForm() url.Values {
return url.Values{
"titel": {"Gesundheitsdaten auswerten"}, "beschreibung": {"KI wertet Gesundheitsdaten aus"},
"ergebnis": {"Auswertung"}, "haeufigkeit": {"gelegentlich"},
"b1": {"nein"}, "b2": {"ja"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
"c4": {"nein"}, "c5": {"ja"},
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
}
}
// seedFreigabeSzenario legt einen Mandanten mit Mitarbeiter (reicht
// einen Antrag ein, der dsfa_erforderlich ausloest), Verantwortlichem
// und einem zentralen Werkzeug an — die Grundlage, auf der die
// einzelnen Freigabe-Workflow-Tests aufbauen.
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter-fg@example.com", "mitarbeiter")
werkzeugForm := url.Values{
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
"training_standard": {"on"}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
}
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber FG", "betreiber-fg@example.com", "betreiber")
if resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm); resp.Code != http.StatusSeeOther {
t.Fatalf("Werkzeug anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
resp := postForm(t, s, mitarbeiterCookie, "/antraege", besondereKategorieAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
for id, a := range fs.antraege {
if a.AccountID == acc.ID {
antragID = id
}
}
if antragID == "" {
t.Fatal("kein Antrag im Mandanten gefunden")
}
verantwortlicherCookie = seedUserInAccount(t, fs, acc.ID, "verantwortlicher-fg@example.com", "verantwortlicher")
return acc.ID, antragID, verantwortlicherCookie
}
// werkzeugIDFor findet die ID des einzigen im fakeStore angelegten
// Werkzeugs — fuer die Genehmigungs-Formulare in diesen Tests reicht ein
// einziges zentrales Werkzeug.
func werkzeugIDFor(fs *fakeStore, name string) string {
for id, w := range fs.werkzeuge {
if w.Name == name {
return id
}
}
return ""
}
func TestOhneFreigabeRegelnVerhaeltSichWieVorher(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
}
if fs.antraege[antragID].Status != "entschieden" {
t.Fatalf("Status = %q, want entschieden (keine Freigabe-Regel konfiguriert)", fs.antraege[antragID].Status)
}
regEintraege := fs.registereintraege[fs.antraege[antragID].AccountID]
if len(regEintraege) != 1 {
t.Fatalf("erwartet 1 Registereintrag ohne Freigabe-Regeln, got %d", len(regEintraege))
}
}
func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err)
}
if _, err := fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID); err != nil {
t.Fatalf("CreateFreigabeRegel: %v", err)
}
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb@example.com", "mitarbeiter")
dsbUser, err := fs.GetUserByEmail(context.Background(), "dsb@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if err := fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
}
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
}
if got := fs.antraege[antragID].Status; got != "wartet_auf_freigabe" {
t.Fatalf("Status = %q, want wartet_auf_freigabe", got)
}
if len(fs.registereintraege[accountID]) != 0 {
t.Fatalf("erwartet noch keinen Registereintrag vor vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
}
listResp := getWithCookie(t, s, dsbCookie, "/freigaben")
if listResp.Code != http.StatusOK || !strings.Contains(listResp.Body.String(), "Gesundheitsdaten auswerten") {
t.Fatalf("Meine Freigaben zeigt den Antrag nicht, status=%d body=%s", listResp.Code, listResp.Body.String())
}
var schrittID string
for id, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID {
schrittID = id
}
}
if schrittID == "" {
t.Fatal("kein Freigabeschritt fuer den Antrag gefunden")
}
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
if decideResp.Code != http.StatusSeeOther {
t.Fatalf("freigabe entscheiden: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
}
if got := fs.antraege[antragID].Status; got != "entschieden" {
t.Fatalf("Status nach vollstaendiger Freigabe = %q, want entschieden", got)
}
if len(fs.registereintraege[accountID]) != 1 {
t.Fatalf("erwartet 1 Registereintrag nach vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
}
}
func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung")
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb2@example.com", "mitarbeiter")
dsbUser, _ := fs.GetUserByEmail(context.Background(), "dsb2@example.com")
fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID)
seedUserInAccount(t, fs, accountID, "gf@example.com", "mitarbeiter")
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
}
var dsbSchrittID string
for id, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID && fsr.GenehmigerRolleID == dsb.ID {
dsbSchrittID = id
}
}
if dsbSchrittID == "" {
t.Fatal("kein DSB-Freigabeschritt gefunden")
}
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+dsbSchrittID+"/entscheiden", url.Values{
"entscheidung": {"abgelehnt"}, "kommentar": {"Nicht ausreichend pseudonymisiert"},
})
if decideResp.Code != http.StatusSeeOther {
t.Fatalf("freigabe ablehnen: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
}
if got := fs.antraege[antragID].Status; got != "entschieden" {
t.Fatalf("Status nach Ablehnung = %q, want entschieden", got)
}
letzte := fs.entscheidungen[antragID]
if len(letzte) == 0 || letzte[len(letzte)-1].Entscheidung != "abgelehnt" {
t.Fatalf("letzte Entscheidung = %+v, want abgelehnt", letzte)
}
for _, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID && fsr.Status == "ausstehend" {
t.Fatalf("erwartet keine ausstehenden Freigabeschritte mehr nach Ablehnung, gefunden: %+v", fsr)
}
}
if len(fs.registereintraege[accountID]) != 0 {
t.Fatalf("erwartet keinen Registereintrag nach Ablehnung, got %d", len(fs.registereintraege[accountID]))
}
}
func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
var schrittID string
for id, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID {
schrittID = id
}
}
resp := postForm(t, s, fremdCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 fuer eine Person ohne die noetige Genehmiger-Rolle", resp.Code)
}
}
func TestGenehmigerRolleCRUD(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "GR-Mandant", "admin-gr@example.com", "admin")
admin, err := fs.GetUserByEmail(context.Background(), "admin-gr@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
resp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen", url.Values{"name": {"Geschaeftsfuehrung"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
var rolleID string
for id, g := range fs.genehmigerRollen {
if g.Name == "Geschaeftsfuehrung" {
rolleID = id
}
}
if rolleID == "" {
t.Fatal("Genehmiger-Rolle wurde nicht angelegt")
}
assignResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder", url.Values{"user_id": {admin.ID}})
if assignResp.Code != http.StatusSeeOther {
t.Fatalf("zuweisen: status = %d, body: %s", assignResp.Code, assignResp.Body.String())
}
list := getWithCookie(t, s, adminCookie, "/verwaltung/genehmiger-rollen")
if !strings.Contains(list.Body.String(), "admin-gr@example.com") {
t.Errorf("erwartet das zugewiesene Mitglied auf der Liste, got: %s", list.Body.String())
}
removeResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder/"+admin.ID+"/entfernen", url.Values{})
if removeResp.Code != http.StatusSeeOther {
t.Fatalf("entfernen: status = %d, body: %s", removeResp.Code, removeResp.Body.String())
}
}

View File

@@ -76,6 +76,27 @@ type Store interface {
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 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
@@ -151,6 +172,16 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
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))

View File

@@ -38,21 +38,30 @@ type fakeStore struct {
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
genehmigerRollen map[string]store.GenehmigerRolle
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
freigabeRegeln map[string]store.FreigabeRegel
freigabeschritte map[string]store.Freigabeschritt
}
func newFakeStore() *fakeStore {
return &fakeStore{
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
registereintraege: map[string][]store.Registereintrag{},
werkzeugSperren: map[string][]store.WerkzeugSperre{},
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
registereintraege: map[string][]store.Registereintrag{},
werkzeugSperren: map[string][]store.WerkzeugSperre{},
genehmigerRollen: map[string]store.GenehmigerRolle{},
nutzerGenehmigerRollen: map[string]map[string]bool{},
freigabeRegeln: map[string]store.FreigabeRegel{},
freigabeschritte: map[string]store.Freigabeschritt{},
}
}
@@ -547,6 +556,208 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
return f.registereintraege[accountID], nil
}
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()}
f.genehmigerRollen[g.ID] = g
return g, nil
}
func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
g, ok := f.genehmigerRollen[id]
if !ok {
return store.GenehmigerRolle{}, store.ErrNotFound
}
return g, nil
}
func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.GenehmigerRolle
for _, g := range f.genehmigerRollen {
if g.AccountID == accountID {
out = append(out, g)
}
}
return out, nil
}
func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.genehmigerRollen[id]; !ok {
return store.ErrNotFound
}
delete(f.genehmigerRollen, id)
return nil
}
func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.nutzerGenehmigerRollen[userID] == nil {
f.nutzerGenehmigerRollen[userID] = map[string]bool{}
}
f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true
return nil
}
func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID)
return nil
}
func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.GenehmigerRolle
for grID := range f.nutzerGenehmigerRollen[userID] {
if g, ok := f.genehmigerRollen[grID]; ok {
out = append(out, g)
}
}
return out, nil
}
func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.GenehmigerMitglied
for userID, set := range f.nutzerGenehmigerRollen {
if !set[genehmigerRolleID] {
continue
}
if u, ok := f.users[userID]; ok {
out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email})
}
}
return out, nil
}
func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) {
f.mu.Lock()
defer f.mu.Unlock()
fr := store.FreigabeRegel{
ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp,
BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(),
}
f.freigabeRegeln[fr.ID] = fr
return fr, nil
}
func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.FreigabeRegel
for _, fr := range f.freigabeRegeln {
if fr.AccountID == accountID {
out = append(out, fr)
}
}
return out, nil
}
func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) {
f.mu.Lock()
defer f.mu.Unlock()
fr, ok := f.freigabeRegeln[id]
if !ok {
return store.FreigabeRegel{}, store.ErrNotFound
}
return fr, nil
}
func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.freigabeRegeln[id]; !ok {
return store.ErrNotFound
}
delete(f.freigabeRegeln, id)
return nil
}
func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
fs := store.Freigabeschritt{
ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID,
Status: "ausstehend", CreatedAt: time.Now(),
}
f.freigabeschritte[fs.ID] = fs
return fs, nil
}
func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
fs, ok := f.freigabeschritte[id]
if !ok {
return store.Freigabeschritt{}, store.ErrNotFound
}
return fs, nil
}
func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Freigabeschritt
for _, fs := range f.freigabeschritte {
if fs.AntragID == antragID {
out = append(out, fs)
}
}
return out, nil
}
func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Freigabeschritt
for grID := range f.nutzerGenehmigerRollen[userID] {
for _, fs := range f.freigabeschritte {
if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" {
out = append(out, fs)
}
}
}
return out, nil
}
func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
fs, ok := f.freigabeschritte[id]
if !ok || fs.Status != "ausstehend" {
return store.Freigabeschritt{}, store.ErrNotFound
}
now := time.Now()
fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar
f.freigabeschritte[id] = fs
return fs, nil
}
func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
f.mu.Lock()
defer f.mu.Unlock()
for id, fs := range f.freigabeschritte {
if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID {
fs.Status = "abgelehnt"
now := time.Now()
fs.EntschiedenAm = &now
fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde."
f.freigabeschritte[id] = fs
}
}
return nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,

View File

@@ -440,6 +440,7 @@ nav button {
.beitraege-liste > li:not(:has(> a)) form {
margin: 0;
flex: 0 0 auto;
max-width: 100%;
}
.beitraege-liste > li:not(:has(> a)) button {

View File

@@ -33,6 +33,23 @@
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
{{if .Freigabeschritte}}
<h2>Zusätzliche Freigaben</h2>
{{if eq .Status "wartet_auf_freigabe"}}
<p class="hinweis">Die Genehmigung ist erst endgültig, wenn alle unten stehenden Freigaben erteilt sind.</p>
{{end}}
<ul class="beitraege-liste">
{{range .Freigabeschritte}}
<li>
{{.RolleName}}
<span class="status {{if eq .Status "genehmigt"}}status-published{{else if eq .Status "abgelehnt"}}status-hoch{{else}}status-mittel{{end}}">{{.Status}}</span>
{{if .Kommentar}}<p>{{.Kommentar}}</p>{{end}}
</li>
{{end}}
</ul>
{{end}}
{{else if .CanEntscheiden}}
<h2>Entscheiden</h2>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}

View File

@@ -0,0 +1,93 @@
{{define "freigabe-regeln-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Freigabe-Regeln</h1>
<p class="hinweis">
"Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
Genehmiger-Rolle Y nötig." Bedingungen sind die vom Regelwerk
abgeleiteten Anforderungen/Einstufungen/Datenklassen — keine
freie Regel, sondern eine Auswahl aus dem, was das System ohnehin
schon berechnet. Eine Genehmigung durch die Fachebene wartet dann
erst auf diese zusätzlichen Freigaben, bevor sie endgültig ist;
lehnt eine davon ab, gilt der gesamte Antrag als abgelehnt.
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if not .RollenOptionen}}
<p class="hinweis">
Zuerst mindestens eine <a href="/verwaltung/genehmiger-rollen">Genehmiger-Rolle</a>
anlegen, bevor eine Freigabe-Regel definiert werden kann.
</p>
{{else}}
<div class="form-card">
<form method="post" action="/verwaltung/freigabe-regeln">
<fieldset class="form-section">
<legend>Neue Freigabe-Regel</legend>
<div class="form-grid">
<div>
<label for="bedingung">Bedingung</label>
<select id="bedingung" name="bedingung" required>
<option value="">Bitte wählen…</option>
{{range .BedingungGruppen}}
{{$gruppe := .Label}}
<optgroup label="{{$gruppe}}">
{{range .Optionen}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</optgroup>
{{end}}
</select>
</div>
<div>
<label for="genehmiger_rolle_id">Genehmiger-Rolle</label>
<select id="genehmiger_rolle_id" name="genehmiger_rolle_id" required>
<option value="">Bitte wählen…</option>
{{range .RollenOptionen}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>
</form>
</div>
{{end}}
{{if not .Regeln}}
<p class="hinweis">Noch keine Freigabe-Regeln.</p>
{{else}}
<div class="table-responsive">
<table>
<thead>
<tr><th>Bedingung</th><th>Erforderliche Freigabe</th><th></th></tr>
</thead>
<tbody>
{{range .Regeln}}
<tr>
<td data-label="Bedingung">{{.BedingungLabel}}</td>
<td data-label="Erforderliche Freigabe">{{.GenehmigerRolle}}</td>
<td data-label="">
<form method="post" action="/verwaltung/freigabe-regeln/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Freigabe-Regel wirklich löschen?')">
<button type="submit" class="entfernen">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,38 @@
{{define "freigaben-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Meine Freigaben</h1>
<p class="hinweis">Anträge, die zusätzlich zur Fachebene-Entscheidung deine Freigabe brauchen.</p>
</div>
</div>
{{if not .Freigaben}}
<p class="keine-findings">Keine ausstehenden Freigaben.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Freigaben}}
<li>
<div class="beteiligter-kopf">
<strong>{{.AntragTitel}}</strong> · Freigabe als {{.RolleName}} · seit {{.CreatedAt}}
</div>
<form method="post" action="/freigaben/{{.ID}}/entscheiden">
<label for="kommentar-{{.ID}}">Kommentar (optional, Pflicht bei Ablehnung empfohlen)</label>
<input type="text" id="kommentar-{{.ID}}" name="kommentar">
<div class="form-actions">
<button type="submit" name="entscheidung" value="genehmigt">Freigeben</button>
<button type="submit" name="entscheidung" value="abgelehnt" class="entfernen" onclick="return confirm('Wirklich ablehnen? Das lehnt den gesamten Antrag ab.')">Ablehnen</button>
</div>
</form>
<p><a href="/faelle/{{.AntragID}}">Antrag ansehen →</a></p>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,79 @@
{{define "genehmiger-rollen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Genehmiger-Rollen</h1>
<p class="hinweis">
Freigabe-Funktionen wie "Datenschutzbeauftragter" oder
"Geschäftsführung" — unabhängig von den normalen Zugriffsrollen,
eine Person kann beides zugleich sein. Wer wann zusätzlich
freigeben muss, legst du unter
<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a> fest.
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/genehmiger-rollen">
<fieldset class="form-section">
<legend>Neue Genehmiger-Rolle</legend>
<div class="form-grid">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>
</form>
</div>
{{if not .Rollen}}
<p class="hinweis">Noch keine Genehmiger-Rollen.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Rollen}}
{{$rolleID := .ID}}
<li>
<div class="beteiligter-kopf"><strong>{{.Name}}</strong></div>
{{if .Mitglieder}}
<ul class="beitraege-liste">
{{range .Mitglieder}}
<li>
{{.Email}}
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder/{{.ID}}/entfernen" style="display:inline">
<button type="submit" class="entfernen">Entfernen</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="hinweis">Noch keine Mitglieder.</p>
{{end}}
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder" style="display:inline">
<select name="user_id" required>
<option value="">Person hinzufügen…</option>
{{range $.Nutzer}}
<option value="{{.ID}}">{{.Email}}</option>
{{end}}
</select>
<button type="submit">Hinzufügen</button>
</form>
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/loeschen" style="display:inline" onsubmit="return confirm('Genehmiger-Rolle wirklich löschen?')">
<button type="submit" class="entfernen">Rolle löschen</button>
</form>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -24,6 +24,7 @@
<nav class="app-nav">
<a href="/">Start</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if not .IsBetreiber}}<a href="/freigaben">Meine Freigaben</a>{{end}}
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
@@ -31,6 +32,8 @@
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>