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:
27
CLAUDE.md
27
CLAUDE.md
@@ -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
|
||||
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
|
||||
|
||||
@@ -15,20 +15,23 @@ import (
|
||||
)
|
||||
|
||||
type GenehmigerRolle struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||
// Mandanten an.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (GenehmigerRolle, error) {
|
||||
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
|
||||
// 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
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, accountID, name).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, name, beschreibung, created_at
|
||||
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if err != nil {
|
||||
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) {
|
||||
var g GenehmigerRolle
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GenehmigerRolle{}, ErrNotFound
|
||||
}
|
||||
@@ -54,7 +57,7 @@ func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRo
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
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
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
@@ -65,7 +68,7 @@ func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID st
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
out = append(out, g)
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func TestFreigabeRegelCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
@@ -113,8 +113,8 @@ func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||
}
|
||||
antragID := antrag.ID
|
||||
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
|
||||
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';
|
||||
@@ -66,6 +66,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
return
|
||||
|
||||
@@ -147,6 +147,13 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
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 {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -9,18 +9,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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 ───────────────────────────────────────
|
||||
|
||||
type genehmigerRolleItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Mitglieder []nutzerOption
|
||||
ID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
Mitglieder []nutzerOption
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
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)
|
||||
if err == nil {
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||
|
||||
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
@@ -166,8 +166,8 @@ func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
|
||||
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")
|
||||
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)
|
||||
|
||||
@@ -223,7 +223,7 @@ func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
|
||||
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||
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)
|
||||
|
||||
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
|
||||
|
||||
@@ -77,7 +77,7 @@ type Store interface {
|
||||
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)
|
||||
CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung 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
|
||||
|
||||
@@ -556,10 +556,10 @@ 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) {
|
||||
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
|
||||
f.mu.Lock()
|
||||
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
|
||||
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) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
|
||||
</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>
|
||||
</fieldset>
|
||||
<div class="form-actions">
|
||||
@@ -42,7 +46,7 @@
|
||||
{{range .Rollen}}
|
||||
{{$rolleID := .ID}}
|
||||
<li>
|
||||
<div class="beteiligter-kopf"><strong>{{.Name}}</strong></div>
|
||||
<div class="beteiligter-kopf"><strong>{{.Name}}</strong>{{if .Beschreibung}} — {{.Beschreibung}}{{end}}</div>
|
||||
{{if .Mitglieder}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Mitglieder}}
|
||||
|
||||
Reference in New Issue
Block a user