- Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5 Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4). - Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als Absicht in der Rollentabelle genannt, nie gebaut. - dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle DPF-Register recherchiert und in der Produktions-DB aktualisiert (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
318 lines
12 KiB
Go
318 lines
12 KiB
Go
package rules
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
|
|
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
|
|
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
|
|
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
|
|
// c2_folge, c3_art).
|
|
type Antworten map[string]any
|
|
|
|
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
|
|
func ParseAntworten(raw []byte) (Antworten, error) {
|
|
if len(raw) == 0 {
|
|
return Antworten{}, nil
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
|
|
}
|
|
return Antworten(m), nil
|
|
}
|
|
|
|
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
|
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
|
|
// hier und nicht schon beim Speichern des Antrags angewendet.
|
|
func (a Antworten) istJaOderUnsicher(key string) bool {
|
|
v, _ := a[key].(string)
|
|
return v == "ja" || v == "unsicher"
|
|
}
|
|
|
|
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
|
|
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
|
|
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
|
|
type DatenklasseErgebnis struct {
|
|
ID string
|
|
Herleitung string
|
|
}
|
|
|
|
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
|
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
|
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
|
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
|
|
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
|
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
|
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
|
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
|
|
const fallback = "intern"
|
|
var gewinner *DatenklasseStufe
|
|
var treffer []string
|
|
for i := range regelwerk.Stufen {
|
|
st := ®elwerk.Stufen[i]
|
|
var stTreffer []string
|
|
for _, ausloeser := range st.Ausloeser {
|
|
if antworten.istJaOderUnsicher(ausloeser) {
|
|
stTreffer = append(stTreffer, ausloeser)
|
|
}
|
|
}
|
|
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
|
gewinner = st
|
|
treffer = stTreffer
|
|
}
|
|
}
|
|
if gewinner == nil {
|
|
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
|
}
|
|
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
|
}
|
|
|
|
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
|
// den Antworten passen (UND-Verknüpfung innerhalb der Variante).
|
|
func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
|
for key, want := range variante {
|
|
got, ok := antworten[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch wantVal := want.(type) {
|
|
case bool:
|
|
gotBool, ok := got.(bool)
|
|
if !ok || gotBool != wantVal {
|
|
return false
|
|
}
|
|
case string:
|
|
gotStr, ok := got.(string)
|
|
if !ok || gotStr != wantVal {
|
|
return false
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// EinstufungErgebnis ist die abgeleitete KI-VO-Einstufung mit Herleitung.
|
|
type EinstufungErgebnis struct {
|
|
ID string
|
|
Quelle string // z. B. "Art. 5 KI-VO", leer wenn die Stufe keine Quelle nennt
|
|
Herleitung string
|
|
}
|
|
|
|
// formatVariante liefert eine deterministische, lesbare Darstellung
|
|
// einer zutreffenden Variante, z. B. "c2=true, c2_folge=kreditwuerdigkeit".
|
|
func formatVariante(v EinstufungVariante) string {
|
|
keys := make([]string, 0, len(v))
|
|
for k := range v {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%v", k, v[k]))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
|
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
|
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
|
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
|
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
|
// LoadEinstufung bereits verhindert sein).
|
|
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
|
for _, st := range regelwerk.Stufen {
|
|
if len(st.Varianten) == 0 {
|
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: "Auffangregel (keine speziellere Stufe traf zu)"}, nil
|
|
}
|
|
for _, variante := range st.Varianten {
|
|
if variantePasst(variante, antworten) {
|
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
|
}
|
|
}
|
|
}
|
|
return EinstufungErgebnis{}, fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
|
}
|
|
|
|
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
|
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
|
func IstVerboten(einstufungID string) bool {
|
|
return einstufungID == "verboten"
|
|
}
|
|
|
|
// AnforderungErgebnis ist eine abgeleitete Anforderung mit Herleitung.
|
|
type AnforderungErgebnis struct {
|
|
ID string
|
|
Beschreibung string
|
|
Herleitung string // z. B. "aus Datenklasse personenbezogen"
|
|
}
|
|
|
|
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
|
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
|
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
|
var out []AnforderungErgebnis
|
|
for _, a := range regelwerk.Anforderungen {
|
|
var gruende []string
|
|
if containsString(a.AusDatenklassen, datenklasseID) {
|
|
gruende = append(gruende, "Datenklasse "+datenklasseID)
|
|
}
|
|
if containsString(a.AusEinstufungen, einstufungID) {
|
|
gruende = append(gruende, "Einstufung "+einstufungID)
|
|
}
|
|
if len(gruende) > 0 {
|
|
out = append(out, AnforderungErgebnis{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: "aus " + strings.Join(gruende, " und ")})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func containsString(list []string, v string) bool {
|
|
for _, x := range list {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WerkzeugEigenschaften sind die für die harte Filterung relevanten
|
|
// Felder eines Katalogeintrags — bewusst ein eigener, schlanker Typ
|
|
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
|
// unabhängig von internal/store und für sich allein testbar.
|
|
type WerkzeugEigenschaften struct {
|
|
AVVVerfuegbar bool
|
|
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
|
|
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
|
|
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
|
|
// genannten Länder EU/EWR-Mitgliedstaaten sind.
|
|
Verarbeitungslaender []string
|
|
TrainingOptOut bool
|
|
TrainingStandard bool
|
|
// AufbewahrungTage ist nil, wenn der Anbieter keine Aufbewahrungsdauer
|
|
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
|
|
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
|
|
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
|
|
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
|
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
|
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
|
// Werkzeug selbst.
|
|
//
|
|
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
|
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
|
|
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
|
|
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
|
|
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
|
|
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
|
|
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
|
|
switch anforderungID {
|
|
case "avv_erforderlich":
|
|
return w.AVVVerfuegbar
|
|
case "eu_verarbeitung":
|
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
|
case "kein_training_auf_eingabe":
|
|
return w.TrainingStandard
|
|
case "loeschfrist_max_tage":
|
|
if loeschfristMaxTage == nil {
|
|
return true
|
|
}
|
|
if w.AufbewahrungTage == nil {
|
|
return false
|
|
}
|
|
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// AusschlussGrund hält fest, warum ein Werkzeug aussortiert wurde —
|
|
// auch aussortierte Werkzeuge werden im Ergebnis gezeigt (siehe
|
|
// Bewertungslogik), nie stillschweigend weggelassen.
|
|
type AusschlussGrund struct {
|
|
WerkzeugID string
|
|
NichtErfuellt []string
|
|
}
|
|
|
|
// WerkzeugKandidat ist ein Katalogeintrag im harten Filter.
|
|
type WerkzeugKandidat struct {
|
|
ID string
|
|
Eigenschaften WerkzeugEigenschaften
|
|
}
|
|
|
|
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
|
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
|
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
|
// ErfuelltAnforderung.
|
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
|
for _, k := range kandidaten {
|
|
var fehlend []string
|
|
for _, reqID := range anforderungIDs {
|
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
|
fehlend = append(fehlend, reqID)
|
|
}
|
|
}
|
|
if len(fehlend) == 0 {
|
|
zulaessig = append(zulaessig, k.ID)
|
|
} else {
|
|
ausgeschlossen = append(ausgeschlossen, AusschlussGrund{WerkzeugID: k.ID, NichtErfuellt: fehlend})
|
|
}
|
|
}
|
|
return zulaessig, ausgeschlossen
|
|
}
|
|
|
|
// String liefert eine lesbare Begründung, z. B. für die
|
|
// Ergebnisdarstellung.
|
|
func (a AusschlussGrund) String() string {
|
|
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
|
}
|
|
|
|
// RegelwerkVersion liefert eine reproduzierbare Kennung des geladenen
|
|
// Regelwerk-Stands — wird mit jeder Bewertung eingefroren, damit im
|
|
// Audit nachvollziehbar bleibt, mit welcher Regelwerk-Version ein
|
|
// Vorschlag erzeugt wurde (analog zu store.CurrentKatalogVersion für
|
|
// den Werkzeugkatalog).
|
|
func RegelwerkVersion(dk DatenklasseRegelwerk, ei EinstufungRegelwerk, an AnforderungsRegelwerk) string {
|
|
return fmt.Sprintf("dk%d.ei%d.an%d", dk.Version, ei.Version, an.Version)
|
|
}
|