Compare commits

...

3 Commits

Author SHA1 Message Date
noroot
59e1bcc8f7 feat: Werkzeugkatalog nennt tatsächliche Verarbeitungsländer statt EU/USA/gemischt-Eimer, plus DPF-Feld
Die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere -
der alte verarbeitungsort-Wertebereich (EU/USA/gemischt/on-prem)
verschleierte das. Ersetzt durch verarbeitungslaender TEXT[] mit
echter Weltländerliste im Formular (Mehrfachauswahl, kein JS nötig).
Der harte Filter eu_verarbeitung verlangt jetzt, dass ALLE genannten
Länder EU/EWR sind. Neues Feld dpf_zertifiziert macht die EU-US Data
Privacy Framework-Zertifizierung strukturiert statt nur Fließtext.
Öffnet nebenbei den Weg für Katalogeinträge außerhalb EU/USA (z. B.
DeepSeek), die vorher am alten Wertebereich scheiterten.

Zusätzlich Typografie-Nachbesserung nach Nutzer-Feedback: Formular-
Abschnittsüberschriften (<legend>) saßen zu dicht am Kartenrand, weil
<legend> das Padding des umschließenden <fieldset> ignoriert - jetzt
mit eigenem Padding und größerer Schrift, h2/h3 einheitlich gesetzt.
2026-08-31 13:52:00 +02:00
noroot
2e459f1dd1 docs: Werkzeugkatalog-Stand aktualisieren (19 Einträge, alle 12 Zweck-Kategorien), Schema-Lücke für Drittland-Hosting dokumentieren
Katalog um 9 weitere recherchierte Werkzeuge erweitert (Notion AI,
Grammarly Business, Midjourney, Julius AI, Synthesia, ElevenLabs,
Zendesk AI, Canva Magic Studio, Zapier) — deckt jetzt alle zwölf
Zweck-Kategorien ab. Midjourney bewusst als Negativbeispiel (kein AVV,
keine EU-Residenz, unbegrenzte Speicherung) aufgenommen statt
weggelassen. DeepSeek NICHT angelegt: verarbeitungsort erlaubt nur
EU/USA/gemischt/on-prem, keiner davon bildet China-Hosting korrekt ab
— Wertebereich müsste zuerst erweitert werden.
2026-08-31 13:17:13 +02:00
noroot
d77b19f61f feat: Zweck-Vokabular auf 12 Kategorien erweitert, Werkzeug-Formular im Enterprise-Look überarbeitet
Sechs neue Zweck-Kategorien (Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten)
ergänzen die ursprünglichen sechs — deckt jetzt die tatsächliche
Bandbreite an Einsatzgebieten ab statt nur klassischer Wissensarbeit.

Werkzeug-Formular (Betreiber + Mandant, gleiche Vorlage) neu strukturiert:
Karten-Layout mit thematischen Abschnitten, 2-Spalten-Grid für kurze
Felder, Chip-Gruppen statt langer Checkbox-Listen, abgesetzte
Gefahrenzone für den Löschen-Button. Dabei einen vorbestehenden Bug
gefunden und behoben: das Lösch-Formular war fest auf die
Betreiber-Route verdrahtet, obwohl dieselbe Vorlage auch vom
Mandanten-Formular genutzt wird.
2026-08-31 13:12:07 +02:00
18 changed files with 715 additions and 246 deletions

151
CLAUDE.md
View File

@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/ Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt. selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
--- ---
@@ -251,7 +251,7 @@ Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
zentralen Katalog zu verändern. zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede **Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt- Zusicherung im Katalog (AVV verfügbar, Verarbeitungsländer, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
@@ -269,13 +269,23 @@ aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
"0 Tage". "0 Tage".
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes `geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen für genau die Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
sechs Zweck-Kategorien aus dem Fragebogen (Textgenerierung, Freitextfelds (`gueltigeZwecke` in `betreiber_werkzeug_handlers.go`,
Übersetzung, Code, Bild, Transkription, Recherche — `gueltigeZwecke` in serverseitig validiert via `istGueltigerZweck`). Grund: ein künftiger
`betreiber_werkzeug_handlers.go`), serverseitig validiert automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
(`istGueltigerZweck`). Grund: ein künftiger automatischer Zweckabgleich gebaut) würde an inkonsistentem Freitext ("Text" statt
(Bewertungslogik Schritt 3, weiterhin nicht gebaut) würde an "Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
inkonsistentem Freitext ("Text" statt "Textgenerierung") scheitern. Kategorien erweitert, nachdem der Nutzer zurecht einwandte, dass sechs
Kategorien die tatsächliche Bandbreite an Einsatzgebieten nicht
abdecken: Textgenerierung, Übersetzung, Code, Bild, Transkription,
Recherche, **Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten**.
Es gibt keinen einheitlichen Branchenstandard für KI-Funktions-
kategorien (regulatorische Taxonomien wie die KI-VO selbst oder OECD/
NIST klassifizieren nach Risiko, nicht nach Funktion; Marktplatz-
Kategorien wie bei G2 sind Marketing, kein Standard) — diese zwölf
sind Deklarix' eigene, bewusst begrenzte Liste für den künftigen
Zweckabgleich, keine allgemeine KI-Taxonomie.
Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot, Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
@@ -285,24 +295,62 @@ scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsort, AVV, Training aus, als Tabelle (Name, Anbieter, Verarbeitungsländer, DPF, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar, als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen. der Rest war erst nach Klick auf den Detaileintrag zu sehen.
Der zentrale Katalog ist aktuell mit 10 recherchierten Einträgen befüllt Der zentrale Katalog ist aktuell mit 19 recherchierten Einträgen
(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace, Work, GitHub Copilot Business, DeepL Pro/API, Google Gemini für Google
Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise, Workspace, Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly
Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien Enterprise, Otter.ai Business, Notion AI, Grammarly Business,
ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI, Canva Magic
kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten, Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. Jede Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten — offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard; und zwar auch dann, wenn keine Quelle eine positive Zusicherung
Otter.ai: Training auf de-identifizierten Aufnahmen, laufende hergibt: Synthesia und Zapier haben `training_standard`/
Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen `training_opt_out` bewusst NICHT gesetzt, weil keine belegte Aussage
versehen statt schöngefärbt, das ist die vorgesehene Funktion des zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
Katalogs. nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
laufende Rechtsstreitigkeiten), **Midjourney (kein AVV, keine
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
weggelassen)**.
**Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
`verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
`verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
`laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
(selbst gehostet)"). Der harte Filter `eu_verarbeitung`
(`internal/rules/evaluate.go`, `alleLaenderInEUEWR`) verlangt jetzt,
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
Feld `dpf_zertifiziert` (Checkbox "EU-US Data Privacy Framework
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
for Work → USA) statt der pauschalen alten Buckets.
Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
Wertebereich, nicht an einer fachlichen Entscheidung.
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des `CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
@@ -319,8 +367,8 @@ bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
Werkzeuge werden im Ergebnis mit Begründung gezeigt. Werkzeuge werden im Ergebnis mit Begründung gezeigt.
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten 3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche) (die zwölf Kategorien aus `gueltigeZwecke`, siehe Werkzeugkatalog
und gegen `geeignete_zwecke` filtern. weiter unten) und gegen `geeignete_zwecke` filtern.
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung, 4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
Prüfung. Prüfung.
@@ -378,7 +426,7 @@ wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsländern. Keine
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount` Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
@@ -467,7 +515,56 @@ kein separates Desktop-Template.
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung **Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`); gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
Scrollen auf dem Handy statt gequetschter Spalten). Scrollen auf dem Handy statt gequetschter Spalten). Seit 2026-08-31 hat
`.table-scroll` zusätzlich einen rechten Fade-Rand (`mask-image`) —
ohne den sah eine am Fensterrand abgeschnittene breite Tabelle wie ein
Rendering-Fehler aus statt wie "hier geht's mit Scrollen weiter" (per
Screenshot am Werkzeugkatalog beobachtet: die letzten zwei Spalten
waren nur als einzelne Buchstaben sichtbar, ganz ohne Hinweis auf mehr
Inhalt).
**Formulare** (`.form-card`/`.form-section`/`.form-grid`/`.chip-group`
in `app.css`, seit 2026-08-31, erstmals angewendet auf
`betreiber_werkzeug_form.html`): vorher war jedes Formular eine flache
Spalte aus `<label>`+`<input>` ohne Gruppierung — bei einem Formular
mit über einem Dutzend Feldern (Werkzeug anlegen) wirkte das "klobig,
einfach nur untereinander" (O-Ton Nutzer). Jetzt: `.form-card` (weiße
Karte mit Rahmen/Schatten, Ant-Design-Tokens aus `design/enterprise.css`
übernommen) enthält mehrere `<fieldset class="form-section">`
(thematische Abschnitte mit Titel, per `border-top` getrennt), darin
`.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
nebeneinander, `.form-full` erzwingt volle Breite für lange Felder).
Checkbox-Gruppen mit überschaubar vielen Optionen (Zusicherungen, die
zwölf Zwecke) sind `.chip-group` — abgerundete Pillen statt einer
langen Untereinander-Liste, ausgewählte Chips werden über
`:has(input:checked)` blau hervorgehoben (kein JavaScript). Ein
Löschen-Button steht nicht mehr einfach unter dem Formular, sondern in
einer abgesetzten `.form-gefahrenzone`-Fußzeile der Karte. Dieselben
Klassen sind bewusst generisch (nicht an ein Formular gebunden) — noch
nicht auf andere Formulare (Login/Registrierung, Fragebogen, übrige
Admin-Formulare) angewendet, das wäre der nächste Schritt, falls
gewünscht. Beim Umbau auffällig gewordener, vorbestehender Bug: das
Lösch-Formular in `betreiber_werkzeug_form.html` war fest auf
`/betreiber/werkzeuge/{id}/loeschen` verdrahtet, obwohl dieselbe
Vorlage auch vom Mandanten-Formular (`/verwaltung/werkzeuge/...`)
genutzt wird — ein Mandant hätte beim Löschen eines eigenen Werkzeugs
403 bekommen. Jetzt `{{.ActionBase}}/{{.ID}}/loeschen`, wie die
Formular-`action` selbst.
**Typografie-Nachbesserung (2026-08-31):** Nutzer-Feedback nach dem
ersten Formular-Redesign: Überschriften wirkten zu klein und zu dicht
am Rand. Ursache für Letzteres: `<legend>` in `<fieldset
class="form-section">` positioniert sich per Spec an der Border-Box
des Fieldsets, nicht an dessen Padding-Box — ohne eigenes Padding sitzt
die Abschnittsüberschrift optisch enger am Kartenrand als der übrige,
per `.form-section { padding: 20px }` eingerückte Inhalt. `.form-section
> legend` hat jetzt selbst `padding: 0 0 16px 0` und eine größere
Schrift (1.0625rem/700 statt 0.9375rem/600). Gleichzeitig `h2`
(1.25rem) und `h3` (1.0625rem) erstmals explizit gesetzt (vorher reiner
Browser-Default) und `.fragebogen legend` auf dieselbe Gewichtsstufe
wie die Formular-Abschnitte angehoben (600/1rem statt 500/0.9375rem) —
für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je
nach UA-Stylesheet.
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per **Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein

View File

@@ -186,11 +186,47 @@ func containsString(list []string, v string) bool {
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so // statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
// unabhängig von internal/store und für sich allein testbar. // unabhängig von internal/store und für sich allein testbar.
type WerkzeugEigenschaften struct { type WerkzeugEigenschaften struct {
AVVVerfuegbar bool AVVVerfuegbar bool
Verarbeitungsort string // Verarbeitungslaender nennt die tatsächlichen Länder statt eines
TrainingOptOut bool // groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
TrainingStandard bool // eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
AufbewahrungTage int // genannten Länder EU/EWR-Mitgliedstaaten sind.
Verarbeitungslaender []string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
}
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
// unabhängig vom EU-US Data Privacy Framework (das mildert die
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
var euEwrLaender = map[string]bool{
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
"Tschechien": true, "Ungarn": true, "Zypern": true,
"Island": true, "Liechtenstein": true, "Norwegen": true,
}
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
func alleLaenderInEUEWR(laender []string) bool {
if len(laender) == 0 {
return false
}
for _, l := range laender {
if !euEwrLaender[l] {
return false
}
}
return true
} }
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung // ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
@@ -206,7 +242,7 @@ func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
case "avv_erforderlich": case "avv_erforderlich":
return w.AVVVerfuegbar return w.AVVVerfuegbar
case "eu_verarbeitung": case "eu_verarbeitung":
return w.Verarbeitungsort == "EU" return alleLaenderInEUEWR(w.Verarbeitungslaender)
case "kein_training_auf_eingabe": case "kein_training_auf_eingabe":
return w.TrainingStandard return w.TrainingStandard
default: default:

View File

@@ -158,6 +158,21 @@ func TestErfuelltAnforderungAVV(t *testing.T) {
} }
} }
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}) {
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
}
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}) {
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
}
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}) {
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
}
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}) {
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
}
}
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) { func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) { if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren") t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
@@ -166,8 +181,8 @@ func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) { func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
kandidaten := []rules.WerkzeugKandidat{ kandidaten := []rules.WerkzeugKandidat{
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}}, {ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}}, {ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
} }
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}) zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" { if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {

View File

@@ -0,0 +1,20 @@
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
-- bestmögliche Näherung statt Datenverlust durch NULL.
UPDATE werkzeug SET verarbeitungsort =
CASE
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
ELSE 'EU'
END;
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;

View File

@@ -0,0 +1,29 @@
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
-- OpenAI/Microsoft).
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
UPDATE werkzeug SET verarbeitungslaender =
CASE verarbeitungsort
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
WHEN 'USA' THEN ARRAY['USA']
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
END;
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
-- filterbar.
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;

View File

@@ -16,15 +16,27 @@ import (
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog // LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
// muss belegbar sein. // muss belegbar sein.
type Werkzeug struct { type Werkzeug struct {
ID string ID string
AccountID *string AccountID *string
Name string Name string
Anbieter string Anbieter string
Verarbeitungsort string // Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
AVVVerfuegbar bool // der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
AVVURL string // wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
TrainingOptOut bool // USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
TrainingStandard bool // "gemischt" verschleierte, welche Länder konkret beteiligt sind.
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
// übrigen Booleans hier.
DPFZertifiziert bool
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
Zertifizierungen []string Zertifizierungen []string
Subprozessoren []string Subprozessoren []string
@@ -40,25 +52,26 @@ type Werkzeug struct {
// Create/Update — bei 15 Feldern lesbarer als eine positionale // Create/Update — bei 15 Feldern lesbarer als eine positionale
// Parameterliste. // Parameterliste.
type WerkzeugInput struct { type WerkzeugInput struct {
AccountID *string AccountID *string
Name string Name string
Anbieter string Anbieter string
Verarbeitungsort string Verarbeitungslaender []string
AVVVerfuegbar bool AVVVerfuegbar bool
AVVURL string AVVURL string
TrainingOptOut bool TrainingOptOut bool
TrainingStandard bool TrainingStandard bool
AufbewahrungTage *int DPFZertifiziert bool
Zertifizierungen []string AufbewahrungTage *int
Subprozessoren []string Zertifizierungen []string
GeeigneteZwecke []string Subprozessoren []string
Einschraenkungen []string GeeigneteZwecke []string
LetztePruefung time.Time Einschraenkungen []string
Quelle string LetztePruefung time.Time
Quelle string
} }
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url, const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren, training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at` geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
func scanWerkzeug(row interface { func scanWerkzeug(row interface {
@@ -66,8 +79,8 @@ func scanWerkzeug(row interface {
}) (Werkzeug, error) { }) (Werkzeug, error) {
var w Werkzeug var w Werkzeug
err := row.Scan( err := row.Scan(
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL, &w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren, &w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt, &w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
) )
return w, err return w, err
@@ -79,6 +92,9 @@ func scanWerkzeug(row interface {
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe // einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
// Fall wie früher bei finding.sources). // Fall wie früher bei finding.sources).
func normalizeWerkzeugSlices(in *WerkzeugInput) { func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Verarbeitungslaender == nil {
in.Verarbeitungslaender = []string{}
}
if in.Zertifizierungen == nil { if in.Zertifizierungen == nil {
in.Zertifizierungen = []string{} in.Zertifizierungen = []string{}
} }
@@ -98,13 +114,13 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
normalizeWerkzeugSlices(&in) normalizeWerkzeugSlices(&in)
row := s.Pool.QueryRow(ctx, ` row := s.Pool.QueryRow(ctx, `
INSERT INTO werkzeug ( INSERT INTO werkzeug (
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren, training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING `+werkzeugColumns, RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL, in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren, in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
) )
w, err := scanWerkzeug(row) w, err := scanWerkzeug(row)
@@ -123,14 +139,14 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
normalizeWerkzeugSlices(&in) normalizeWerkzeugSlices(&in)
row := s.Pool.QueryRow(ctx, ` row := s.Pool.QueryRow(ctx, `
UPDATE werkzeug SET UPDATE werkzeug SET
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6, name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9, training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
zertifizierungen = $10, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13, zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
letzte_pruefung = $14, quelle = $15, updated_at = now() letzte_pruefung = $15, quelle = $16, updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING `+werkzeugColumns, RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL, id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren, in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
) )
w, err := scanWerkzeug(row) w, err := scanWerkzeug(row)

View File

@@ -13,21 +13,21 @@ func intPtr(v int) *int { return &v }
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput { func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
return store.WerkzeugInput{ return store.WerkzeugInput{
AccountID: accountID, AccountID: accountID,
Name: name, Name: name,
Anbieter: "Beispiel-Anbieter GmbH", Anbieter: "Beispiel-Anbieter GmbH",
Verarbeitungsort: "EU", Verarbeitungslaender: []string{"Deutschland"},
AVVVerfuegbar: true, AVVVerfuegbar: true,
AVVURL: "https://beispiel.example/avv", AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true, TrainingOptOut: true,
TrainingStandard: true, TrainingStandard: true,
AufbewahrungTage: intPtr(30), AufbewahrungTage: intPtr(30),
Zertifizierungen: []string{"ISO 27001"}, Zertifizierungen: []string{"ISO 27001"},
Subprozessoren: []string{"Beispiel-Subprozessor Inc."}, Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
GeeigneteZwecke: []string{"Textgenerierung"}, GeeigneteZwecke: []string{"Textgenerierung"},
Einschraenkungen: nil, Einschraenkungen: nil,
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond), LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
Quelle: "https://beispiel.example/beleg", Quelle: "https://beispiel.example/beleg",
} }
} }

View File

@@ -294,7 +294,7 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
aufbewahrungTage = *w.AufbewahrungTage aufbewahrungTage = *w.AufbewahrungTage
} }
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{ kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort, AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage, TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
}} }}
} }

View File

@@ -14,11 +14,12 @@ import (
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"} // istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
func istGueltigerVerarbeitungsort(v string) bool { // nur für Verarbeitungsländer statt Zwecke.
for _, o := range gueltigeVerarbeitungsorte { func istGueltigesLand(v string) bool {
if o == v { for _, l := range weltLaenderliste {
if l == v {
return true return true
} }
} }
@@ -26,12 +27,17 @@ func istGueltigerVerarbeitungsort(v string) bool {
} }
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke" // gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
// — exakt die sechs Zweck-Kategorien aus dem Fragebogen/der geplanten // (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
// Zweckabgleich-Stufe (siehe CLAUDE.md, Bewertungslogik Schritt 3). // Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
// Vorher war das Feld Freitext; ein Tippfehler ("Text" statt // künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
// "Textgenerierung") hätte den künftigen automatischen Zweckabgleich // 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
// unbemerkt kaputt gemacht. // v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
var gueltigeZwecke = []string{"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche"} // — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
var gueltigeZwecke = []string{
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
}
func istGueltigerZweck(v string) bool { func istGueltigerZweck(v string) bool {
for _, z := range gueltigeZwecke { for _, z := range gueltigeZwecke {
@@ -57,18 +63,19 @@ func splitListe(s string) []string {
} }
type werkzeugListItem struct { type werkzeugListItem struct {
ID string ID string
Name string Name string
Anbieter string Anbieter string
Verarbeitungsort string Verarbeitungslaender string
AVVVerfuegbar bool DPFZertifiziert bool
TrainingStandard bool AVVVerfuegbar bool
Aufbewahrung string TrainingStandard bool
Zertifizierungen string Aufbewahrung string
GeeigneteZwecke string Zertifizierungen string
LetztePruefung string GeeigneteZwecke string
PruefStatusLabel string LetztePruefung string
PruefStatusCSS string PruefStatusLabel string
PruefStatusCSS string
} }
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat // formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
@@ -129,8 +136,9 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
for _, wz := range werkzeuge { for _, wz := range werkzeuge {
label, css := pruefStatus(wz.LetztePruefung) label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{ data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard, DPFZertifiziert: wz.DPFZertifiziert,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage), Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
@@ -147,32 +155,33 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge") // Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html. // — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
type werkzeugFormData struct { type werkzeugFormData struct {
Title string Title string
Nav navData Nav navData
ActionBase string ActionBase string
ID string // leer bei "neu" ID string // leer bei "neu"
Name string Name string
Anbieter string Anbieter string
Verarbeitungsorte []string LaenderOptionen []string
Verarbeitungsort string LaenderAusgewaehlt map[string]bool
AVVVerfuegbar bool DPFZertifiziert bool
AVVURL string AVVVerfuegbar bool
TrainingOptOut bool AVVURL string
TrainingStandard bool TrainingOptOut bool
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0" TrainingStandard bool
Zertifizierungen string AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Subprozessoren string Zertifizierungen string
ZweckeOptionen []string Subprozessoren string
ZweckeAusgewaehlt map[string]bool ZweckeOptionen []string
Einschraenkungen string ZweckeAusgewaehlt map[string]bool
LetztePruefung string Einschraenkungen string
Quelle string LetztePruefung string
Error string Quelle string
Error string
} }
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits // auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
// gespeicherten oder erneut eingereichten Liste von Zwecken. // gespeicherten oder erneut eingereichten Liste von Zwecken.
func zweckeAusgewaehltVon(ausgewaehlt []string) map[string]bool { func auswahlSet(ausgewaehlt []string) map[string]bool {
m := make(map[string]bool, len(ausgewaehlt)) m := make(map[string]bool, len(ausgewaehlt))
for _, z := range ausgewaehlt { for _, z := range ausgewaehlt {
m[z] = true m[z] = true
@@ -189,12 +198,13 @@ func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, act
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage) aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
} }
return werkzeugFormData{ return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte, Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
Name: wz.Name, Anbieter: wz.Anbieter,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL, AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung, TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "), Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(wz.GeeigneteZwecke), ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"), Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle, Quelle: wz.Quelle,
} }
@@ -203,7 +213,8 @@ func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, act
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{ data := werkzeugFormData{
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{}, LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
} }
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
@@ -218,9 +229,14 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
if err != nil { if err != nil {
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt" return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
} }
verarbeitungsort := r.FormValue("verarbeitungsort") var verarbeitungslaender []string
if !istGueltigerVerarbeitungsort(verarbeitungsort) { for _, l := range r.Form["verarbeitungslaender"] {
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort" if istGueltigesLand(l) {
verarbeitungslaender = append(verarbeitungslaender, l)
}
}
if len(verarbeitungslaender) == 0 {
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
} }
name := r.FormValue("name") name := r.FormValue("name")
anbieter := r.FormValue("anbieter") anbieter := r.FormValue("anbieter")
@@ -245,21 +261,22 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
} }
return store.WerkzeugInput{ return store.WerkzeugInput{
AccountID: accountID, AccountID: accountID,
Name: name, Name: name,
Anbieter: anbieter, Anbieter: anbieter,
Verarbeitungsort: verarbeitungsort, Verarbeitungslaender: verarbeitungslaender,
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
AVVURL: r.FormValue("avv_url"), AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingOptOut: r.FormValue("training_opt_out") == "on",
TrainingStandard: r.FormValue("training_standard") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: aufbewahrungTage, DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")), AufbewahrungTage: aufbewahrungTage,
Subprozessoren: splitListe(r.FormValue("subprozessoren")), Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
GeeigneteZwecke: geeigneteZwecke, Subprozessoren: splitListe(r.FormValue("subprozessoren")),
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")), GeeigneteZwecke: geeigneteZwecke,
LetztePruefung: letztePruefung, Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
Quelle: quelle, LetztePruefung: letztePruefung,
Quelle: quelle,
}, "" }, ""
} }
@@ -268,13 +285,15 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
// statt das Formular zu leeren. // statt das Formular zu leeren.
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData { func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
return werkzeugFormData{ return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte, Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"), LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"), AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on", TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")), AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"), Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(r.Form["geeignete_zwecke"]), ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"), Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
Quelle: r.FormValue("quelle"), Error: errMsg, Quelle: r.FormValue("quelle"), Error: errMsg,
} }

View File

@@ -12,7 +12,7 @@ import (
func werkzeugForm() url.Values { func werkzeugForm() url.Values {
return url.Values{ return url.Values{
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"}, "name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"}, "avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"}, "training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"}, "zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},

View File

@@ -0,0 +1,53 @@
package web
// weltLaenderliste ist die kontrollierte Auswahl für "Verarbeitungsländer"
// im Werkzeugkatalog — eine echte Länderliste statt grober Eimer wie
// "EU"/"USA"/"gemischt" (die USA sind DSGVO-rechtlich bereits ein
// Drittland wie jedes andere, siehe CLAUDE.md). Enthält zusätzlich zwei
// Pseudo-Einträge für Fälle, in denen der Anbieter selbst keinen
// einzelnen Staat zusichert bzw. keine Cloud-Verarbeitung stattfindet.
var weltLaenderliste = []string{
"EU-Region (kein einzelnes Land benannt)",
"On-Premise (selbst gehostet)",
"Afghanistan", "Ägypten", "Albanien", "Algerien", "Andorra", "Angola",
"Antigua und Barbuda", "Äquatorialguinea", "Argentinien", "Armenien",
"Aserbaidschan", "Äthiopien", "Australien", "Bahamas", "Bahrain",
"Bangladesch", "Barbados", "Belgien", "Belize", "Benin", "Bhutan",
"Bolivien", "Bosnien und Herzegowina", "Botsuana", "Brasilien",
"Brunei", "Bulgarien", "Burkina Faso", "Burundi", "Chile", "China",
"Costa Rica", "Dänemark", "Deutschland", "Dominica",
"Dominikanische Republik", "Dschibuti", "Ecuador", "El Salvador",
"Elfenbeinküste", "Eritrea", "Estland", "Eswatini", "Fidschi",
"Finnland", "Frankreich", "Gabun", "Gambia", "Georgien", "Ghana",
"Grenada", "Griechenland", "Guatemala", "Guinea", "Guinea-Bissau",
"Guyana", "Haiti", "Honduras", "Indien", "Indonesien", "Irak",
"Iran", "Irland", "Island", "Israel", "Italien", "Jamaika", "Japan",
"Jemen", "Jordanien", "Kambodscha", "Kamerun", "Kanada", "Kap Verde",
"Kasachstan", "Katar", "Kenia", "Kirgisistan", "Kiribati",
"Kolumbien", "Komoren", "Kongo (Republik)",
"Kongo (Demokratische Republik)", "Kosovo", "Kroatien", "Kuba",
"Kuwait", "Laos", "Lesotho", "Lettland", "Libanon", "Liberia",
"Libyen", "Liechtenstein", "Litauen", "Luxemburg", "Madagaskar",
"Malawi", "Malaysia", "Malediven", "Mali", "Malta", "Marokko",
"Marshallinseln", "Mauretanien", "Mauritius", "Mexiko",
"Mikronesien", "Moldau", "Monaco", "Mongolei", "Montenegro",
"Mosambik", "Myanmar", "Namibia", "Nauru", "Nepal", "Neuseeland",
"Nicaragua", "Niederlande", "Niger", "Nigeria", "Nordkorea",
"Nordmazedonien", "Norwegen", "Oman", "Österreich", "Osttimor",
"Pakistan", "Palau", "Palästina", "Panama", "Papua-Neuguinea",
"Paraguay", "Peru", "Philippinen", "Polen", "Portugal", "Ruanda",
"Rumänien", "Russland", "Salomonen", "Sambia", "Samoa",
"San Marino", "São Tomé und Príncipe", "Saudi-Arabien", "Schweden",
"Schweiz", "Senegal", "Serbien", "Seychellen", "Sierra Leone",
"Simbabwe", "Singapur", "Slowakei", "Slowenien", "Somalia",
"Spanien", "Sri Lanka", "St. Kitts und Nevis", "St. Lucia",
"St. Vincent und die Grenadinen", "Südafrika", "Sudan", "Südkorea",
"Südsudan", "Suriname", "Syrien", "Tadschikistan", "Taiwan",
"Tansania", "Thailand", "Togo", "Tonga", "Trinidad und Tobago",
"Tschad", "Tschechien", "Tunesien", "Türkei", "Turkmenistan",
"Tuvalu", "Uganda", "Ukraine", "Ungarn", "Uruguay", "USA",
"Usbekistan", "Vanuatu", "Vatikanstadt", "Venezuela",
"Vereinigte Arabische Emirate", "Vereinigtes Königreich", "Vietnam",
"Zentralafrikanische Republik", "Zypern",
}

View File

@@ -14,26 +14,26 @@ import (
) )
type mandantWerkzeugItem struct { type mandantWerkzeugItem struct {
ID string ID string
Name string Name string
Anbieter string Anbieter string
Verarbeitungsort string Verarbeitungslaender string
AVVVerfuegbar bool AVVVerfuegbar bool
TrainingStandard bool TrainingStandard bool
Aufbewahrung string Aufbewahrung string
GeeigneteZwecke string GeeigneteZwecke string
LetztePruefung string LetztePruefung string
PruefStatusLabel string PruefStatusLabel string
PruefStatusCSS string PruefStatusCSS string
Gesperrt bool Gesperrt bool
} }
type eigenesWerkzeugItem struct { type eigenesWerkzeugItem struct {
ID string ID string
Name string Name string
Anbieter string Anbieter string
Verarbeitungsort string Verarbeitungslaender string
LetztePruefung string LetztePruefung string
} }
type mandantWerkzeugListeData struct { type mandantWerkzeugListeData struct {
@@ -73,7 +73,7 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
for _, wz := range zentral { for _, wz := range zentral {
label, css := pruefStatus(wz.LetztePruefung) label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{ data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard, AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage), Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
@@ -87,7 +87,7 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
continue // zentral, oben schon gelistet continue // zentral, oben schon gelistet
} }
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
}) })
} }
@@ -99,7 +99,8 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{ data := werkzeugFormData{
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{}, LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
} }
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)

View File

@@ -408,9 +408,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
defer f.mu.Unlock() defer f.mu.Unlock()
w := store.Werkzeug{ w := store.Werkzeug{
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter, ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL, Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage, TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen, Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(), LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
} }
f.werkzeuge[w.ID] = w f.werkzeuge[w.ID] = w
@@ -424,10 +424,10 @@ func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.Werk
if !ok { if !ok {
return store.Werkzeug{}, store.ErrNotFound return store.Werkzeug{}, store.ErrNotFound
} }
w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now() w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
f.werkzeuge[id] = w f.werkzeuge[id] = w
return w, nil return w, nil

View File

@@ -59,7 +59,15 @@ h1, h2, h3 {
color: var(--color-heading); color: var(--color-heading);
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin: 0 0 0.5em; margin: 0 0 0.6em;
}
h2 {
font-size: 1.25rem;
}
h3 {
font-size: 1.0625rem;
} }
h1 { h1 {
@@ -542,8 +550,8 @@ button.entfernen {
} }
.fragebogen legend { .fragebogen legend {
font-weight: 500; font-weight: 600;
font-size: 0.9375rem; font-size: 1rem;
padding: 0 4px; padding: 0 4px;
} }
@@ -555,6 +563,130 @@ button.entfernen {
margin: 4px 12px 4px 0; margin: 4px 12px 4px 0;
} }
/* ── Formular-Karten (Enterprise-Look, siehe design/enterprise.css
.ant-card/.ant-form-item) ─────────────────────────────────────────
Ersetzt "ein Formular = eine flache, ununterbrochene Spalte" durch
klar abgegrenzte Abschnitte mit Titel und ein Grid für kurze Felder
nebeneinander statt jedes Feld einzeln untereinander. */
.form-card {
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: 24px;
overflow: hidden;
}
.form-card form {
margin-bottom: 0;
}
.form-section {
border: none;
margin: 0;
padding: 20px;
min-width: 0;
}
.form-section + .form-section {
border-top: 1px solid var(--color-border);
}
/* <legend> ignoriert das padding des umschließenden <fieldset> (es
positioniert sich an dessen Border-Box, nicht an der Padding-Box) —
ohne eigenes padding-left säße die Abschnittsüberschrift bündig am
Kartenrand, während der restliche Inhalt eingerückt ist. */
.form-section > legend {
padding: 0 0 16px 0;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-heading);
width: 100%;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0 16px;
}
@media (min-width: 640px) {
.form-grid {
grid-template-columns: 1fr 1fr;
}
.form-grid .form-full {
grid-column: 1 / -1;
}
}
/* Checkbox-/Radio-Gruppen als Chips statt einer langen
Untereinander-Liste — v. a. bei den zwölf Zweck-Kategorien war das
vorher eine kaum überblickbare Spalte. */
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.chip-group label {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 0;
padding: 7px 14px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text);
background: var(--color-bg);
cursor: pointer;
}
.chip-group input[type="checkbox"],
.chip-group input[type="radio"] {
width: auto;
min-height: 0;
margin: 0;
}
.chip-group label:has(input:checked) {
border-color: var(--branding-primary);
background: rgba(22, 119, 255, 0.08);
color: var(--branding-primary);
}
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
background: var(--color-bg);
}
.form-actions form,
.form-actions button {
margin: 0;
}
.form-gefahrenzone {
border-top: 1px solid var(--color-border);
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.form-gefahrenzone form {
margin: 0;
}
.fragebogen fieldset input[type="radio"] { .fragebogen fieldset input[type="radio"] {
width: auto; width: auto;
min-height: 0; min-height: 0;

View File

@@ -7,66 +7,113 @@
<p><a href="{{.ActionBase}}">&larr; Werkzeugkatalog</a></p> <p><a href="{{.ActionBase}}">&larr; Werkzeugkatalog</a></p>
<h1>{{.Title}}</h1> <h1>{{.Title}}</h1>
<p class="hinweis"> <p class="hinweis">
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out, Jede Zusicherung hier (AVV, Verarbeitungsländer, Training-Opt-out,
Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind
Pflicht. Pflicht.
</p> </p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}} {{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}"> <form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
<label for="name">Name</label> <fieldset class="form-section">
<input type="text" id="name" name="name" value="{{.Name}}" required> <legend>Grunddaten</legend>
<div class="form-grid">
<label for="anbieter">Anbieter</label> <div>
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required> <label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
<fieldset> </div>
<legend>Verarbeitungsort</legend> <div>
{{$gewaehlt := .Verarbeitungsort}} <label for="anbieter">Anbieter</label>
{{range .Verarbeitungsorte}} <input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label> </div>
{{end}} <div class="form-full">
<label for="verarbeitungslaender">Verarbeitungsländer (Mehrfachauswahl: Strg/Cmd-Klick)</label>
<select id="verarbeitungslaender" name="verarbeitungslaender" multiple size="8" required>
{{range .LaenderOptionen}}
<option value="{{.}}" {{if index $.LaenderAusgewaehlt .}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<p class="hinweis">Tatsächliche Länder benennen, nicht "EU"/"Drittland" — die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere.</p>
</div>
</div>
</fieldset> </fieldset>
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label> <fieldset class="form-section">
<label for="avv_url">AVV-URL</label> <legend>Rechtliches &amp; Datenschutz</legend>
<input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}"> <div class="form-grid">
<div class="form-full">
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label> <label>Zusicherungen</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label> <div class="chip-group">
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label> <label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}"> <label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
<label><input type="checkbox" name="dpf_zertifiziert" {{if .DPFZertifiziert}}checked{{end}}> EU-US Data Privacy Framework (DPF) zertifiziert</label>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label> </div>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}"> </div>
<div class="form-full">
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label> <label for="avv_url">AVV-URL</label>
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}"> <input type="text" id="avv_url" name="avv_url" value="{{.AVVURL}}">
</div>
<fieldset> <div>
<legend>Geeignete Zwecke</legend> <label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
{{range .ZweckeOptionen}} <input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label> </div>
{{end}} <div>
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
</div>
<div class="form-full">
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
</div>
</div>
</fieldset> </fieldset>
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label> <fieldset class="form-section">
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}"> <legend>Einsatzbereich</legend>
<div class="form-grid">
<div class="form-full">
<label>Geeignete Zwecke</label>
<div class="chip-group">
{{range .ZweckeOptionen}}
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
{{end}}
</div>
</div>
<div class="form-full">
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
</div>
</div>
</fieldset>
<label for="letzte_pruefung">Letzte Prüfung</label> <fieldset class="form-section">
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required> <legend>Beleg</legend>
<div class="form-grid">
<div>
<label for="letzte_pruefung">Letzte Prüfung</label>
<input type="date" id="letzte_pruefung" name="letzte_pruefung" value="{{.LetztePruefung}}" required>
</div>
<div>
<label for="quelle">Quelle (Beleg)</label>
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
</div>
</div>
</fieldset>
<label for="quelle">Quelle (Beleg)</label> <div class="form-actions">
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required> <button type="submit">Speichern</button>
</div>
<button type="submit">Speichern</button>
</form> </form>
{{if .ID}} {{if .ID}}
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')"> <div class="form-gefahrenzone">
<button type="submit" class="entfernen">Werkzeug löschen</button> <span class="hinweis">Ein gelöschtes Werkzeug kann nicht wiederhergestellt werden.</span>
</form> <form method="post" action="{{.ActionBase}}/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit" class="entfernen">Werkzeug löschen</button>
</form>
</div>
{{end}} {{end}}
</div> </div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -13,7 +13,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th> <th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th> <th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th> <th>Zuletzt geprüft</th>
</tr> </tr>
@@ -23,7 +23,8 @@
<tr> <tr>
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td> <td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td> <td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td> <td>{{.Verarbeitungslaender}}</td>
<td>{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td> <td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td> <td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td> <td>{{.Aufbewahrung}}</td>

View File

@@ -19,7 +19,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th> <th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th> <th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
</tr> </tr>
</thead> </thead>
@@ -28,7 +28,7 @@
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td>{{.Anbieter}}</td> <td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td> <td>{{.Verarbeitungslaender}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td> <td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td> <td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td> <td>{{.Aufbewahrung}}</td>
@@ -65,14 +65,14 @@
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
<thead> <thead>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>Zuletzt geprüft</th></tr> <tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
</thead> </thead>
<tbody> <tbody>
{{range .EigeneWerkzeuge}} {{range .EigeneWerkzeuge}}
<tr> <tr>
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td> <td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td> <td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td> <td>{{.Verarbeitungslaender}}</td>
<td>{{.LetztePruefung}}</td> <td>{{.LetztePruefung}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -9,6 +9,8 @@ package web
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings"
"time" "time"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
@@ -30,8 +32,9 @@ func werkzeugDiff(snapshot, current store.Werkzeug) []string {
if snapshot.TrainingStandard != current.TrainingStandard { if snapshot.TrainingStandard != current.TrainingStandard {
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard)) diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
} }
if snapshot.Verarbeitungsort != current.Verarbeitungsort { if !slices.Equal(snapshot.Verarbeitungslaender, current.Verarbeitungslaender) {
diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort)) diffs = append(diffs, fmt.Sprintf("Verarbeitungsländer: %s → %s",
strings.Join(snapshot.Verarbeitungslaender, ", "), strings.Join(current.Verarbeitungslaender, ", ")))
} }
return diffs return diffs
} }