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.
This commit is contained in:
noroot
2026-08-31 13:52:00 +02:00
parent 2e459f1dd1
commit 59e1bcc8f7
18 changed files with 425 additions and 192 deletions

View File

@@ -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
@@ -295,7 +295,7 @@ 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.
@@ -324,16 +324,33 @@ CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
geeignet, absichtlich als Negativbeispiel im Katalog, nicht geeignet, absichtlich als Negativbeispiel im Katalog, nicht
weggelassen)**. weggelassen)**.
**Bekannte Schema-Lücke (2026-08-31, noch nicht behoben):** Anbieter **Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
mit Verarbeitung außerhalb EU/USA (z. B. DeepSeek, China) lassen sich `verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
mit dem aktuellen `verarbeitungsort`-Wertebereich (`EU`/`USA`/ `verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
`gemischt`/`on-prem`, DB-CHECK-Constraint) nicht korrekt abbilden — Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
"gemischt" würde fälschlich EU-Berührung suggerieren, "USA" wäre ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
schlicht falsch. DeepSeek wurde deshalb bewusst NICHT angelegt statt Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
mit einem falschen Wert erzwungen. Vor dem nächsten Katalogeintrag bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
außerhalb EU/USA/on-prem-Hosting muss der Wertebereich erweitert werden `laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
(z. B. um `drittland`) — eigene Migration nötig, Entscheidung noch Klickkein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
nicht getroffen. (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
@@ -409,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
@@ -517,7 +534,7 @@ Karte mit Rahmen/Schatten, Ant-Design-Tokens aus `design/enterprise.css`
(thematische Abschnitte mit Titel, per `border-top` getrennt), darin (thematische Abschnitte mit Titel, per `border-top` getrennt), darin
`.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter `.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
nebeneinander, `.form-full` erzwingt volle Breite für lange Felder). nebeneinander, `.form-full` erzwingt volle Breite für lange Felder).
Checkbox-/Radio-Gruppen mit vielen Optionen (Verarbeitungsort, die Checkbox-Gruppen mit überschaubar vielen Optionen (Zusicherungen, die
zwölf Zwecke) sind `.chip-group` — abgerundete Pillen statt einer zwölf Zwecke) sind `.chip-group` — abgerundete Pillen statt einer
langen Untereinander-Liste, ausgewählte Chips werden über langen Untereinander-Liste, ausgewählte Chips werden über
`:has(input:checked)` blau hervorgehoben (kein JavaScript). Ein `:has(input:checked)` blau hervorgehoben (kein JavaScript). Ein
@@ -534,6 +551,21 @@ genutzt wird — ein Mandant hätte beim Löschen eines eigenen Werkzeugs
403 bekommen. Jetzt `{{.ActionBase}}/{{.ID}}/loeschen`, wie die 403 bekommen. Jetzt `{{.ActionBase}}/{{.ID}}/loeschen`, wie die
Formular-`action` selbst. 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
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt` websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`

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
} }
} }
@@ -62,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
@@ -134,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"),
@@ -152,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
@@ -194,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,
} }
@@ -208,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)
@@ -223,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")
@@ -250,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,
}, "" }, ""
} }
@@ -273,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;
} }
@@ -584,10 +592,14 @@ button.entfernen {
border-top: 1px solid var(--color-border); 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 { .form-section > legend {
padding: 0 0 12px; padding: 0 0 16px 0;
font-size: 0.9375rem; font-size: 1.0625rem;
font-weight: 600; font-weight: 700;
color: var(--color-heading); color: var(--color-heading);
width: 100%; width: 100%;
} }

View File

@@ -7,7 +7,7 @@
<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>
@@ -26,13 +26,13 @@
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required> <input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
</div> </div>
<div class="form-full"> <div class="form-full">
<label>Verarbeitungsort</label> <label for="verarbeitungslaender">Verarbeitungsländer (Mehrfachauswahl: Strg/Cmd-Klick)</label>
{{$gewaehlt := .Verarbeitungsort}} <select id="verarbeitungslaender" name="verarbeitungslaender" multiple size="8" required>
<div class="chip-group"> {{range .LaenderOptionen}}
{{range .Verarbeitungsorte}} <option value="{{.}}" {{if index $.LaenderAusgewaehlt .}}selected{{end}}>{{.}}</option>
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
{{end}} {{end}}
</div> </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>
</div> </div>
</fieldset> </fieldset>
@@ -46,6 +46,7 @@
<label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label> <label><input type="checkbox" name="avv_verfuegbar" {{if .AVVVerfuegbar}}checked{{end}}> AVV verfügbar</label>
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label> <label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label> <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>
</div> </div>
</div> </div>
<div class="form-full"> <div class="form-full">

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
} }