feat: Standard-Genehmiger-Rollen automatisch bei Firmenanlage anlegen

Jede neue Firma (Registrierung + Betreiber-Firmenanlage) bekommt jetzt
automatisch vier leere Genehmiger-Rollen mit erklärender Beschreibung
(Datenschutzbeauftragter, Geschäftsführer, KI-Manager, CISO) - Admin
muss nur noch Personen zuordnen statt bei null anzufangen. Welche
Bedingung welche Rolle tatsächlich auslöst, bleibt weiterhin komplett
konfigurierbar pro Firma (Migration 0018 fügt genehmiger_rolle.beschreibung
als reines Freitext-Orientierungsfeld hinzu, keine feste fachliche
Bindung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-31 22:18:49 +02:00
parent f729e5ae48
commit 690660b655
12 changed files with 156 additions and 30 deletions

View File

@@ -588,6 +588,33 @@ wie spezifiziert. Chromium-Headless-Screenshots (Desktop 1440×900 +
Mobile 390×844) aller vier neuen/geänderten Seiten bestätigen zusätzlich Mobile 390×844) aller vier neuen/geänderten Seiten bestätigen zusätzlich
das responsive Layout nach dem enconf-Card-Pattern. das responsive Layout nach dem enconf-Card-Pattern.
**Standard-Genehmiger-Rollen bei Firmenanlage (2026-08-31, Migration
0018).** Jede neue Firma bekommt jetzt automatisch vier leere
Genehmiger-Rollen mit erklärender Beschreibung (`standardGenehmigerRollen`
in `internal/web/freigabe_handlers.go`, aufgerufen aus `handleRegister`
und `handleBetreiberAccountCreate` direkt nach `CreateAccount`):
- **Datenschutzbeauftragter** — "Prüft Anträge mit besonderen
Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung
erforderlich ist."
- **Geschäftsführer** — "Trifft die unternehmerische Entscheidung bei
besonders risikobehafteten Vorhaben."
- **KI-Manager** — "Bewertet den Antrag fachlich."
- **CISO** — "Prüft den Antrag aus Sicherheitssicht (Auditor)."
Bewusst **ohne** Mitglieder und **ohne** Freigabe-Regel — der Admin muss
nur noch Personen zuordnen (`/verwaltung/genehmiger-rollen`) und
optional eine auslösende Bedingung definieren
(`/verwaltung/freigabe-regeln`), statt bei null anzufangen. Welche
Bedingung welche Rolle tatsächlich auslöst, bleibt **vollständig
Sache der Firma** — das System schreibt keine feste fachliche Bindung
vor (z. B. keine hartkodierte Kopplung "Geschäftsführer ⇒ hochrisiko");
die Beschreibung ist reine UI-Orientierungshilfe, kein Ableitungsregel-
Ersatz. `genehmiger_rolle.beschreibung` ist ein freies, optionales
Textfeld (`NOT NULL DEFAULT ''`) — auch für vom Admin selbst angelegte
zusätzliche Rollen nutzbar. Getestet via `TestRegisterSeedsStandardGenehmigerRollen`
und live per curl gegen echten Server + Postgres verifiziert.
--- ---
## Onboarding ## Onboarding

View File

@@ -15,20 +15,23 @@ import (
) )
type GenehmigerRolle struct { type GenehmigerRolle struct {
ID string ID string
AccountID string AccountID string
Name string Name string
CreatedAt time.Time Beschreibung string
CreatedAt time.Time
} }
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen // CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
// Mandanten an. // Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (GenehmigerRolle, error) { // Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
// Bedingung, das bleibt Sache von freigabe_regel.
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
var g GenehmigerRolle var g GenehmigerRolle
err := s.Pool.QueryRow(ctx, ` err := s.Pool.QueryRow(ctx, `
INSERT INTO genehmiger_rolle (account_id, name) VALUES ($1, $2) INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
RETURNING id, account_id, name, created_at RETURNING id, account_id, name, beschreibung, created_at
`, accountID, name).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt) `, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
if err != nil { if err != nil {
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err) return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
} }
@@ -39,8 +42,8 @@ func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name strin
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) { func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
var g GenehmigerRolle var g GenehmigerRolle
err := s.Pool.QueryRow(ctx, ` err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, name, created_at FROM genehmiger_rolle WHERE id = $1 SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt) `, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return GenehmigerRolle{}, ErrNotFound return GenehmigerRolle{}, ErrNotFound
} }
@@ -54,7 +57,7 @@ func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRo
// Mandanten. // Mandanten.
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) { func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
rows, err := s.Pool.Query(ctx, ` rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, name, created_at FROM genehmiger_rolle SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
WHERE account_id = $1 ORDER BY name WHERE account_id = $1 ORDER BY name
`, accountID) `, accountID)
if err != nil { if err != nil {
@@ -65,7 +68,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st
var out []GenehmigerRolle var out []GenehmigerRolle
for rows.Next() { for rows.Next() {
var g GenehmigerRolle var g GenehmigerRolle
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil { if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err) return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
} }
out = append(out, g) out = append(out, g)

View File

@@ -13,7 +13,7 @@ func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
ctx := context.Background() ctx := context.Background()
accID := testAccountID(t, s) accID := testAccountID(t, s)
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter") g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
if err != nil { if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err) t.Fatalf("CreateGenehmigerRolle: %v", err)
} }
@@ -71,7 +71,7 @@ func TestFreigabeRegelCRUD(t *testing.T) {
s := openTestStore(t) s := openTestStore(t)
ctx := context.Background() ctx := context.Background()
accID := testAccountID(t, s) accID := testAccountID(t, s)
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung") g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
if err != nil { if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err) t.Fatalf("CreateGenehmigerRolle: %v", err)
} }
@@ -113,8 +113,8 @@ func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
} }
antragID := antrag.ID antragID := antrag.ID
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter") dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung") gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID) schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
if err != nil { if err != nil {

View File

@@ -0,0 +1 @@
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;

View File

@@ -0,0 +1 @@
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';

View File

@@ -66,6 +66,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Genehmiger-Rollen konnten nicht angelegt werden"})
return
}
if err := s.startSession(w, r, user.ID); err != nil { if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"}) s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
return return

View File

@@ -147,6 +147,13 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
} }
return return
} }
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Genehmiger-Rollen konnten nicht angelegt werden: " + err.Error()}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil { if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return return

View File

@@ -9,18 +9,48 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
// standardGenehmigerRollen wird bei jeder neuen Firma automatisch angelegt
// (ohne Mitglieder/Freigabe-Regeln) — der Admin muss nur noch Personen
// zuordnen und bei Bedarf Freigabe-Regeln ergänzen, statt bei null
// anzufangen. Die Beschreibung ist reine Orientierungshilfe im UI; welche
// Bedingung welche Rolle tatsächlich auslöst, entscheidet weiterhin jede
// Firma selbst über /verwaltung/freigabe-regeln — das System schreibt
// keine fachliche Bindung vor.
var standardGenehmigerRollen = []struct {
Name string
Beschreibung string
}{
{"Datenschutzbeauftragter", "Prüft Anträge mit besonderen Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung erforderlich ist."},
{"Geschäftsführer", "Trifft die unternehmerische Entscheidung bei besonders risikobehafteten Vorhaben."},
{"KI-Manager", "Bewertet den Antrag fachlich."},
{"CISO", "Prüft den Antrag aus Sicherheitssicht (Auditor)."},
}
// seedStandardGenehmigerRollen wird direkt nach dem Anlegen einer neuen
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage).
func (s *Server) seedStandardGenehmigerRollen(ctx context.Context, accountID string) error {
for _, r := range standardGenehmigerRollen {
if _, err := s.store.CreateGenehmigerRolle(ctx, accountID, r.Name, r.Beschreibung); err != nil {
return err
}
}
return nil
}
// ─── Admin: Genehmiger-Rollen ─────────────────────────────────────── // ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
type genehmigerRolleItem struct { type genehmigerRolleItem struct {
ID string ID string
Name string Name string
Mitglieder []nutzerOption Beschreibung string
Mitglieder []nutzerOption
} }
type nutzerOption struct { type nutzerOption struct {
@@ -53,7 +83,7 @@ func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Requ
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email}) data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
} }
for _, g := range rollen { for _, g := range rollen {
item := genehmigerRolleItem{ID: g.ID, Name: g.Name} item := genehmigerRolleItem{ID: g.ID, Name: g.Name, Beschreibung: g.Beschreibung}
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID) mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
if err == nil { if err == nil {
for _, m := range mitglieder { for _, m := range mitglieder {
@@ -77,7 +107,8 @@ func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Requ
http.Error(w, "Name ist Pflicht", http.StatusBadRequest) http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
return return
} }
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name); err != nil { beschreibung := strings.TrimSpace(r.FormValue("beschreibung"))
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name, beschreibung); err != nil {
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return return
} }

View File

@@ -104,7 +104,7 @@ func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s) accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise") werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter") dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
if err != nil { if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err) t.Fatalf("CreateGenehmigerRolle: %v", err)
} }
@@ -166,8 +166,8 @@ func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s) accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise") werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter") dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung") gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung", "")
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID) fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID) fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
@@ -223,7 +223,7 @@ func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s) accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise") werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter") dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID) fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter") fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")

View File

@@ -77,7 +77,7 @@ type Store interface {
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error)
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
DeleteGenehmigerRolle(ctx context.Context, id string) error DeleteGenehmigerRolle(ctx context.Context, id string) error

View File

@@ -556,10 +556,10 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
return f.registereintraege[accountID], nil return f.registereintraege[accountID], nil
} }
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error) { func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()} g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, Beschreibung: beschreibung, CreatedAt: time.Now()}
f.genehmigerRollen[g.ID] = g f.genehmigerRollen[g.ID] = g
return g, nil return g, nil
} }
@@ -944,6 +944,53 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
} }
} }
func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
"password": {"ein-sicheres-passwort"},
})
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
var accountID string
for _, u := range fs.users {
if u.Email == "neu2@example.com" {
accountID = u.AccountID
}
}
if accountID == "" {
t.Fatal("neuer Nutzer nicht gefunden")
}
rollen, err := fs.ListGenehmigerRollenForAccount(context.Background(), accountID)
if err != nil {
t.Fatalf("ListGenehmigerRollenForAccount: %v", err)
}
wantNamen := []string{"Datenschutzbeauftragter", "Geschäftsführer", "KI-Manager", "CISO"}
if len(rollen) != len(wantNamen) {
t.Fatalf("got %d Genehmiger-Rollen, want %d", len(rollen), len(wantNamen))
}
var namen []string
for _, r := range rollen {
namen = append(namen, r.Name)
}
for _, want := range wantNamen {
found := false
for _, n := range namen {
if n == want {
found = true
}
}
if !found {
t.Fatalf("Standard-Rolle %q wurde nicht angelegt, vorhanden: %v", want, namen)
}
}
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) { func TestRegisterRejectsDuplicateEmail(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fs) s := newServer(t, fs)

View File

@@ -27,6 +27,10 @@
<label for="name">Name</label> <label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required> <input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
</div> </div>
<div>
<label for="beschreibung">Beschreibung (optional)</label>
<input type="text" id="beschreibung" name="beschreibung" placeholder="z. B. Prüft den Antrag aus Sicherheitssicht">
</div>
</div> </div>
</fieldset> </fieldset>
<div class="form-actions"> <div class="form-actions">
@@ -42,7 +46,7 @@
{{range .Rollen}} {{range .Rollen}}
{{$rolleID := .ID}} {{$rolleID := .ID}}
<li> <li>
<div class="beteiligter-kopf"><strong>{{.Name}}</strong></div> <div class="beteiligter-kopf"><strong>{{.Name}}</strong>{{if .Beschreibung}} — {{.Beschreibung}}{{end}}</div>
{{if .Mitglieder}} {{if .Mitglieder}}
<ul class="beitraege-liste"> <ul class="beitraege-liste">
{{range .Mitglieder}} {{range .Mitglieder}}