From 690660b65503ec3d10c3b28da61bfa2d346630e0 Mon Sep 17 00:00:00 2001 From: noroot Date: Mon, 31 Aug 2026 22:18:49 +0200 Subject: [PATCH] feat: Standard-Genehmiger-Rollen automatisch bei Firmenanlage anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 27 ++++++++++ internal/store/freigabe.go | 29 ++++++----- internal/store/freigabe_test.go | 8 +-- ...018_genehmiger_rolle_beschreibung.down.sql | 1 + .../0018_genehmiger_rolle_beschreibung.up.sql | 1 + internal/web/auth_handlers.go | 5 ++ internal/web/betreiber_handlers.go | 7 +++ internal/web/freigabe_handlers.go | 41 +++++++++++++-- internal/web/freigabe_handlers_test.go | 8 +-- internal/web/server.go | 2 +- internal/web/server_test.go | 51 ++++++++++++++++++- .../templates/genehmiger_rollen_liste.html | 6 ++- 12 files changed, 156 insertions(+), 30 deletions(-) create mode 100644 internal/store/migrations/0018_genehmiger_rolle_beschreibung.down.sql create mode 100644 internal/store/migrations/0018_genehmiger_rolle_beschreibung.up.sql diff --git a/CLAUDE.md b/CLAUDE.md index 6e93363..ea4644f 100644 --- a/CLAUDE.md +++ b/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 diff --git a/internal/store/freigabe.go b/internal/store/freigabe.go index 2f7bede..78c1e03 100644 --- a/internal/store/freigabe.go +++ b/internal/store/freigabe.go @@ -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) diff --git a/internal/store/freigabe_test.go b/internal/store/freigabe_test.go index e0d10ef..8c4695b 100644 --- a/internal/store/freigabe_test.go +++ b/internal/store/freigabe_test.go @@ -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 { diff --git a/internal/store/migrations/0018_genehmiger_rolle_beschreibung.down.sql b/internal/store/migrations/0018_genehmiger_rolle_beschreibung.down.sql new file mode 100644 index 0000000..5737243 --- /dev/null +++ b/internal/store/migrations/0018_genehmiger_rolle_beschreibung.down.sql @@ -0,0 +1 @@ +ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung; diff --git a/internal/store/migrations/0018_genehmiger_rolle_beschreibung.up.sql b/internal/store/migrations/0018_genehmiger_rolle_beschreibung.up.sql new file mode 100644 index 0000000..208ba70 --- /dev/null +++ b/internal/store/migrations/0018_genehmiger_rolle_beschreibung.up.sql @@ -0,0 +1 @@ +ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT ''; diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index b3801ee..bd1882d 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -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 diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index 08490c7..b65af5a 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -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 diff --git a/internal/web/freigabe_handlers.go b/internal/web/freigabe_handlers.go index 57f22b8..90d1474 100644 --- a/internal/web/freigabe_handlers.go +++ b/internal/web/freigabe_handlers.go @@ -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 } diff --git a/internal/web/freigabe_handlers_test.go b/internal/web/freigabe_handlers_test.go index 82b34bd..4518e80 100644 --- a/internal/web/freigabe_handlers_test.go +++ b/internal/web/freigabe_handlers_test.go @@ -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") diff --git a/internal/web/server.go b/internal/web/server.go index 64f9683..6c86e06 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 diff --git a/internal/web/server_test.go b/internal/web/server_test.go index b38a343..9e339e7 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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) diff --git a/internal/web/templates/genehmiger_rollen_liste.html b/internal/web/templates/genehmiger_rollen_liste.html index ab28927..2d1d31e 100644 --- a/internal/web/templates/genehmiger_rollen_liste.html +++ b/internal/web/templates/genehmiger_rollen_liste.html @@ -27,6 +27,10 @@ +
+ + +
@@ -42,7 +46,7 @@ {{range .Rollen}} {{$rolleID := .ID}}
  • -
    {{.Name}}
    +
    {{.Name}}{{if .Beschreibung}} — {{.Beschreibung}}{{end}}
    {{if .Mitglieder}}
      {{range .Mitglieder}}