Initial commit: breakpilot-compliance - Compliance SDK Platform

Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View File

@@ -0,0 +1,769 @@
package ucca
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// ============================================================================
// AI Act Module
// ============================================================================
//
// This module implements the EU AI Act (Regulation 2024/1689) which establishes
// harmonized rules for artificial intelligence systems in the EU.
//
// The AI Act uses a risk-based approach:
// - Unacceptable Risk: Prohibited practices (Art. 5)
// - High Risk: Annex III systems with strict requirements (Art. 6-49)
// - Limited Risk: Transparency obligations (Art. 50)
// - Minimal Risk: No additional requirements
//
// Key roles:
// - Provider: Develops or places AI on market
// - Deployer: Uses AI systems in professional activity
// - Distributor: Makes AI available on market
// - Importer: Brings AI from third countries
//
// ============================================================================
// AIActRiskLevel represents the AI Act risk classification
type AIActRiskLevel string
const (
AIActUnacceptable AIActRiskLevel = "unacceptable"
AIActHighRisk AIActRiskLevel = "high_risk"
AIActLimitedRisk AIActRiskLevel = "limited_risk"
AIActMinimalRisk AIActRiskLevel = "minimal_risk"
AIActNotApplicable AIActRiskLevel = "not_applicable"
)
// AIActModule implements the RegulationModule interface for the AI Act
type AIActModule struct {
obligations []Obligation
controls []ObligationControl
incidentDeadlines []IncidentDeadline
decisionTree *DecisionTree
loaded bool
}
// Annex III High-Risk AI Categories
var AIActAnnexIIICategories = map[string]string{
"biometric": "Biometrische Identifizierung und Kategorisierung",
"critical_infrastructure": "Verwaltung und Betrieb kritischer Infrastruktur",
"education": "Allgemeine und berufliche Bildung",
"employment": "Beschaeftigung, Personalverwaltung, Zugang zu Selbststaendigkeit",
"essential_services": "Zugang zu wesentlichen privaten/oeffentlichen Diensten",
"law_enforcement": "Strafverfolgung",
"migration": "Migration, Asyl und Grenzkontrolle",
"justice": "Rechtspflege und demokratische Prozesse",
}
// NewAIActModule creates a new AI Act module, loading obligations from YAML
func NewAIActModule() (*AIActModule, error) {
m := &AIActModule{
obligations: []Obligation{},
controls: []ObligationControl{},
incidentDeadlines: []IncidentDeadline{},
}
// Try to load from YAML, fall back to hardcoded if not found
if err := m.loadFromYAML(); err != nil {
// Use hardcoded defaults
m.loadHardcodedObligations()
}
m.buildDecisionTree()
m.loaded = true
return m, nil
}
// ID returns the module identifier
func (m *AIActModule) ID() string {
return "ai_act"
}
// Name returns the human-readable name
func (m *AIActModule) Name() string {
return "AI Act (EU KI-Verordnung)"
}
// Description returns a brief description
func (m *AIActModule) Description() string {
return "EU-Verordnung 2024/1689 zur Festlegung harmonisierter Vorschriften fuer kuenstliche Intelligenz"
}
// IsApplicable checks if the AI Act applies to the organization
func (m *AIActModule) IsApplicable(facts *UnifiedFacts) bool {
// AI Act applies if organization uses, provides, or deploys AI systems in the EU
if !facts.AIUsage.UsesAI {
return false
}
// Check if in EU or offering to EU
if !facts.Organization.EUMember && !facts.DataProtection.OffersToEU {
return false
}
return true
}
// GetClassification returns the AI Act risk classification as string
func (m *AIActModule) GetClassification(facts *UnifiedFacts) string {
return string(m.ClassifyRisk(facts))
}
// ClassifyRisk determines the highest applicable AI Act risk level
func (m *AIActModule) ClassifyRisk(facts *UnifiedFacts) AIActRiskLevel {
if !facts.AIUsage.UsesAI {
return AIActNotApplicable
}
// Check for prohibited practices (Art. 5)
if m.hasProhibitedPractice(facts) {
return AIActUnacceptable
}
// Check for high-risk (Annex III)
if m.hasHighRiskAI(facts) {
return AIActHighRisk
}
// Check for limited risk (transparency requirements)
if m.hasLimitedRiskAI(facts) {
return AIActLimitedRisk
}
// Minimal risk - general AI usage
if facts.AIUsage.UsesAI {
return AIActMinimalRisk
}
return AIActNotApplicable
}
// hasProhibitedPractice checks if any prohibited AI practices are present
func (m *AIActModule) hasProhibitedPractice(facts *UnifiedFacts) bool {
// Art. 5 AI Act - Prohibited practices
if facts.AIUsage.SocialScoring {
return true
}
if facts.AIUsage.EmotionRecognition && (facts.Sector.PrimarySector == "education" ||
facts.AIUsage.EmploymentDecisions) {
// Emotion recognition in workplace/education
return true
}
if facts.AIUsage.PredictivePolicingIndividual {
return true
}
// Biometric real-time remote identification in public spaces (with limited exceptions)
if facts.AIUsage.BiometricIdentification && facts.AIUsage.LawEnforcement {
// Generally prohibited, exceptions for specific law enforcement scenarios
return true
}
return false
}
// hasHighRiskAI checks if any Annex III high-risk AI categories apply
func (m *AIActModule) hasHighRiskAI(facts *UnifiedFacts) bool {
// Explicit high-risk flag
if facts.AIUsage.HasHighRiskAI {
return true
}
// Annex III categories
if facts.AIUsage.BiometricIdentification {
return true
}
if facts.AIUsage.CriticalInfrastructure {
return true
}
if facts.AIUsage.EducationAccess {
return true
}
if facts.AIUsage.EmploymentDecisions {
return true
}
if facts.AIUsage.EssentialServices {
return true
}
if facts.AIUsage.LawEnforcement {
return true
}
if facts.AIUsage.MigrationAsylum {
return true
}
if facts.AIUsage.JusticeAdministration {
return true
}
// Also check if in critical infrastructure sector with AI
if facts.Sector.IsKRITIS && facts.AIUsage.UsesAI {
return true
}
return false
}
// hasLimitedRiskAI checks if limited risk transparency requirements apply
func (m *AIActModule) hasLimitedRiskAI(facts *UnifiedFacts) bool {
// Explicit limited-risk flag
if facts.AIUsage.HasLimitedRiskAI {
return true
}
// AI that interacts with natural persons
if facts.AIUsage.AIInteractsWithNaturalPersons {
return true
}
// Deepfake generation
if facts.AIUsage.GeneratesDeepfakes {
return true
}
// Emotion recognition (not in prohibited contexts)
if facts.AIUsage.EmotionRecognition &&
facts.Sector.PrimarySector != "education" &&
!facts.AIUsage.EmploymentDecisions {
return true
}
// Chatbots and AI assistants typically fall here
return false
}
// isProvider checks if organization is an AI provider
func (m *AIActModule) isProvider(facts *UnifiedFacts) bool {
return facts.AIUsage.IsAIProvider
}
// isDeployer checks if organization is an AI deployer
func (m *AIActModule) isDeployer(facts *UnifiedFacts) bool {
return facts.AIUsage.IsAIDeployer || (facts.AIUsage.UsesAI && !facts.AIUsage.IsAIProvider)
}
// isGPAIProvider checks if organization provides General Purpose AI
func (m *AIActModule) isGPAIProvider(facts *UnifiedFacts) bool {
return facts.AIUsage.UsesGPAI && facts.AIUsage.IsAIProvider
}
// hasSystemicRiskGPAI checks if GPAI has systemic risk
func (m *AIActModule) hasSystemicRiskGPAI(facts *UnifiedFacts) bool {
return facts.AIUsage.GPAIWithSystemicRisk
}
// requiresFRIA checks if Fundamental Rights Impact Assessment is required
func (m *AIActModule) requiresFRIA(facts *UnifiedFacts) bool {
// FRIA required for public bodies and certain high-risk deployers
if !m.hasHighRiskAI(facts) {
return false
}
// Public authorities using high-risk AI
if facts.Organization.IsPublicAuthority {
return true
}
// Certain categories always require FRIA
if facts.AIUsage.EssentialServices {
return true
}
if facts.AIUsage.EmploymentDecisions {
return true
}
if facts.AIUsage.EducationAccess {
return true
}
return false
}
// DeriveObligations derives all applicable AI Act obligations
func (m *AIActModule) DeriveObligations(facts *UnifiedFacts) []Obligation {
if !m.IsApplicable(facts) {
return []Obligation{}
}
riskLevel := m.ClassifyRisk(facts)
var result []Obligation
for _, obl := range m.obligations {
if m.obligationApplies(obl, riskLevel, facts) {
// Copy and customize obligation
customized := obl
customized.RegulationID = m.ID()
result = append(result, customized)
}
}
return result
}
// obligationApplies checks if a specific obligation applies
func (m *AIActModule) obligationApplies(obl Obligation, riskLevel AIActRiskLevel, facts *UnifiedFacts) bool {
switch obl.AppliesWhen {
case "uses_ai":
return facts.AIUsage.UsesAI
case "high_risk":
return riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable
case "high_risk_provider":
return (riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable) && m.isProvider(facts)
case "high_risk_deployer":
return (riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable) && m.isDeployer(facts)
case "high_risk_deployer_fria":
return (riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable) && m.isDeployer(facts) && m.requiresFRIA(facts)
case "limited_risk":
return riskLevel == AIActLimitedRisk || riskLevel == AIActHighRisk
case "gpai_provider":
return m.isGPAIProvider(facts)
case "gpai_systemic_risk":
return m.hasSystemicRiskGPAI(facts)
case "":
// No condition = applies to all AI users
return facts.AIUsage.UsesAI
default:
return facts.AIUsage.UsesAI
}
}
// DeriveControls derives all applicable AI Act controls
func (m *AIActModule) DeriveControls(facts *UnifiedFacts) []ObligationControl {
if !m.IsApplicable(facts) {
return []ObligationControl{}
}
var result []ObligationControl
for _, ctrl := range m.controls {
ctrl.RegulationID = m.ID()
result = append(result, ctrl)
}
return result
}
// GetDecisionTree returns the AI Act applicability decision tree
func (m *AIActModule) GetDecisionTree() *DecisionTree {
return m.decisionTree
}
// GetIncidentDeadlines returns AI Act incident reporting deadlines
func (m *AIActModule) GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline {
riskLevel := m.ClassifyRisk(facts)
if riskLevel != AIActHighRisk && riskLevel != AIActUnacceptable {
return []IncidentDeadline{}
}
return m.incidentDeadlines
}
// ============================================================================
// YAML Loading
// ============================================================================
func (m *AIActModule) loadFromYAML() error {
// Search paths for YAML file
searchPaths := []string{
"policies/obligations/ai_act_obligations.yaml",
filepath.Join(".", "policies", "obligations", "ai_act_obligations.yaml"),
filepath.Join("..", "policies", "obligations", "ai_act_obligations.yaml"),
filepath.Join("..", "..", "policies", "obligations", "ai_act_obligations.yaml"),
"/app/policies/obligations/ai_act_obligations.yaml",
}
var data []byte
var err error
for _, path := range searchPaths {
data, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("AI Act obligations YAML not found: %w", err)
}
var config NIS2ObligationsConfig // Reuse same config structure
if err := yaml.Unmarshal(data, &config); err != nil {
return fmt.Errorf("failed to parse AI Act YAML: %w", err)
}
// Convert YAML to internal structures
m.convertObligations(config.Obligations)
m.convertControls(config.Controls)
m.convertIncidentDeadlines(config.IncidentDeadlines)
return nil
}
func (m *AIActModule) convertObligations(yamlObls []ObligationYAML) {
for _, y := range yamlObls {
obl := Obligation{
ID: y.ID,
RegulationID: "ai_act",
Title: y.Title,
Description: y.Description,
AppliesWhen: y.AppliesWhen,
Category: ObligationCategory(y.Category),
Responsible: ResponsibleRole(y.Responsible),
Priority: ObligationPriority(y.Priority),
ISO27001Mapping: y.ISO27001,
HowToImplement: y.HowTo,
}
// Convert legal basis
for _, lb := range y.LegalBasis {
obl.LegalBasis = append(obl.LegalBasis, LegalReference{
Norm: lb.Norm,
Article: lb.Article,
})
}
// Convert deadline
if y.Deadline != nil {
obl.Deadline = &Deadline{
Type: DeadlineType(y.Deadline.Type),
Duration: y.Deadline.Duration,
}
if y.Deadline.Date != "" {
if t, err := time.Parse("2006-01-02", y.Deadline.Date); err == nil {
obl.Deadline.Date = &t
}
}
}
// Convert sanctions
if y.Sanctions != nil {
obl.Sanctions = &SanctionInfo{
MaxFine: y.Sanctions.MaxFine,
PersonalLiability: y.Sanctions.PersonalLiability,
}
}
// Convert evidence
for _, e := range y.Evidence {
obl.Evidence = append(obl.Evidence, EvidenceItem{Name: e, Required: true})
}
m.obligations = append(m.obligations, obl)
}
}
func (m *AIActModule) convertControls(yamlCtrls []ControlYAML) {
for _, y := range yamlCtrls {
ctrl := ObligationControl{
ID: y.ID,
RegulationID: "ai_act",
Name: y.Name,
Description: y.Description,
Category: y.Category,
WhatToDo: y.WhatToDo,
ISO27001Mapping: y.ISO27001,
Priority: ObligationPriority(y.Priority),
}
m.controls = append(m.controls, ctrl)
}
}
func (m *AIActModule) convertIncidentDeadlines(yamlDeadlines []IncidentDeadlineYAML) {
for _, y := range yamlDeadlines {
deadline := IncidentDeadline{
RegulationID: "ai_act",
Phase: y.Phase,
Deadline: y.Deadline,
Content: y.Content,
Recipient: y.Recipient,
}
for _, lb := range y.LegalBasis {
deadline.LegalBasis = append(deadline.LegalBasis, LegalReference{
Norm: lb.Norm,
Article: lb.Article,
})
}
m.incidentDeadlines = append(m.incidentDeadlines, deadline)
}
}
// ============================================================================
// Hardcoded Fallback
// ============================================================================
func (m *AIActModule) loadHardcodedObligations() {
// Key AI Act deadlines
prohibitedPracticesDeadline := time.Date(2025, 2, 2, 0, 0, 0, 0, time.UTC)
transparencyDeadline := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)
gpaiDeadline := time.Date(2025, 8, 2, 0, 0, 0, 0, time.UTC)
m.obligations = []Obligation{
{
ID: "AIACT-OBL-001",
RegulationID: "ai_act",
Title: "Verbotene KI-Praktiken vermeiden",
Description: "Sicherstellung, dass keine verbotenen KI-Praktiken eingesetzt werden (Social Scoring, Ausnutzung von Schwaechen, unterschwellige Manipulation, unzulaessige biometrische Identifizierung).",
LegalBasis: []LegalReference{{Norm: "Art. 5 AI Act", Article: "Verbotene Praktiken"}},
Category: CategoryCompliance,
Responsible: RoleManagement,
Deadline: &Deadline{Type: DeadlineAbsolute, Date: &prohibitedPracticesDeadline},
Sanctions: &SanctionInfo{MaxFine: "35 Mio. EUR oder 7% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "KI-Inventar mit Risikobewertung", Required: true}, {Name: "Dokumentierte Pruefung auf verbotene Praktiken", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "uses_ai",
},
{
ID: "AIACT-OBL-002",
RegulationID: "ai_act",
Title: "Risikomanagementsystem fuer Hochrisiko-KI",
Description: "Einrichtung eines Risikomanagementsystems fuer Hochrisiko-KI-Systeme: Risikoidentifikation, -bewertung, -minderung und kontinuierliche Ueberwachung.",
LegalBasis: []LegalReference{{Norm: "Art. 9 AI Act", Article: "Risikomanagementsystem"}},
Category: CategoryGovernance,
Responsible: RoleKIVerantwortlicher,
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Risikomanagement-Dokumentation", Required: true}, {Name: "Risikobewertungen pro KI-System", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "high_risk",
ISO27001Mapping: []string{"A.5.1.1", "A.8.2"},
},
{
ID: "AIACT-OBL-003",
RegulationID: "ai_act",
Title: "Technische Dokumentation erstellen",
Description: "Erstellung umfassender technischer Dokumentation vor Inverkehrbringen: Systembeschreibung, Design-Spezifikationen, Entwicklungsprozess, Leistungsmetriken.",
LegalBasis: []LegalReference{{Norm: "Art. 11 AI Act", Article: "Technische Dokumentation"}},
Category: CategoryGovernance,
Responsible: RoleKIVerantwortlicher,
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Technische Dokumentation nach Anhang IV", Required: true}, {Name: "Systemarchitektur-Dokumentation", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "high_risk_provider",
},
{
ID: "AIACT-OBL-004",
RegulationID: "ai_act",
Title: "Protokollierungsfunktion implementieren",
Description: "Hochrisiko-KI-Systeme muessen automatische Protokolle (Logs) erstellen: Nutzungszeitraum, Eingabedaten, Identitaet der verifizierenden Personen.",
LegalBasis: []LegalReference{{Norm: "Art. 12 AI Act", Article: "Aufzeichnungspflichten"}},
Category: CategoryTechnical,
Responsible: RoleITLeitung,
Deadline: &Deadline{Type: DeadlineRelative, Duration: "Aufbewahrung mindestens 6 Monate"},
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Log-System-Dokumentation", Required: true}, {Name: "Aufbewahrungsrichtlinie", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "high_risk",
ISO27001Mapping: []string{"A.12.4"},
},
{
ID: "AIACT-OBL-005",
RegulationID: "ai_act",
Title: "Menschliche Aufsicht sicherstellen",
Description: "Hochrisiko-KI muss menschliche Aufsicht ermoeglichen: Verstehen von Faehigkeiten und Grenzen, Ueberwachung, Eingreifen oder Abbrechen koennen.",
LegalBasis: []LegalReference{{Norm: "Art. 14 AI Act", Article: "Menschliche Aufsicht"}},
Category: CategoryOrganizational,
Responsible: RoleKIVerantwortlicher,
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Aufsichtskonzept", Required: true}, {Name: "Schulungsnachweise fuer Bediener", Required: true}, {Name: "Notfall-Abschaltprozedur", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "high_risk",
},
{
ID: "AIACT-OBL-006",
RegulationID: "ai_act",
Title: "Betreiberpflichten fuer Hochrisiko-KI",
Description: "Betreiber von Hochrisiko-KI muessen: Technische und organisatorische Massnahmen treffen, Eingabedaten pruefen, Betrieb ueberwachen, Protokolle aufbewahren.",
LegalBasis: []LegalReference{{Norm: "Art. 26 AI Act", Article: "Pflichten der Betreiber"}},
Category: CategoryOrganizational,
Responsible: RoleKIVerantwortlicher,
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Betriebskonzept", Required: true}, {Name: "Monitoring-Dokumentation", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "high_risk_deployer",
},
{
ID: "AIACT-OBL-007",
RegulationID: "ai_act",
Title: "Grundrechte-Folgenabschaetzung (FRIA)",
Description: "Betreiber von Hochrisiko-KI in sensiblen Bereichen muessen vor Einsatz eine Grundrechte-Folgenabschaetzung durchfuehren.",
LegalBasis: []LegalReference{{Norm: "Art. 27 AI Act", Article: "Grundrechte-Folgenabschaetzung"}},
Category: CategoryGovernance,
Responsible: RoleKIVerantwortlicher,
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "FRIA-Dokumentation", Required: true}, {Name: "Risikobewertung Grundrechte", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "high_risk_deployer_fria",
},
{
ID: "AIACT-OBL-008",
RegulationID: "ai_act",
Title: "Transparenzpflichten fuer KI-Interaktionen",
Description: "Bei KI-Systemen, die mit natuerlichen Personen interagieren: Kennzeichnung der KI-Interaktion, Information ueber KI-generierte Inhalte, Kennzeichnung von Deep Fakes.",
LegalBasis: []LegalReference{{Norm: "Art. 50 AI Act", Article: "Transparenzpflichten"}},
Category: CategoryOrganizational,
Responsible: RoleKIVerantwortlicher,
Deadline: &Deadline{Type: DeadlineAbsolute, Date: &transparencyDeadline},
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Kennzeichnungskonzept", Required: true}, {Name: "Nutzerhinweise", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "limited_risk",
},
{
ID: "AIACT-OBL-009",
RegulationID: "ai_act",
Title: "GPAI-Modell Dokumentation",
Description: "Anbieter von GPAI-Modellen muessen technische Dokumentation erstellen, Informationen fuer nachgelagerte Anbieter bereitstellen und Urheberrechtsrichtlinie einhalten.",
LegalBasis: []LegalReference{{Norm: "Art. 53 AI Act", Article: "Pflichten der Anbieter von GPAI-Modellen"}},
Category: CategoryGovernance,
Responsible: RoleKIVerantwortlicher,
Deadline: &Deadline{Type: DeadlineAbsolute, Date: &gpaiDeadline},
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "GPAI-Dokumentation", Required: true}, {Name: "Trainingsdaten-Summary", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "gpai_provider",
},
{
ID: "AIACT-OBL-010",
RegulationID: "ai_act",
Title: "KI-Kompetenz sicherstellen",
Description: "Anbieter und Betreiber muessen sicherstellen, dass Personal mit ausreichender KI-Kompetenz ausgestattet ist.",
LegalBasis: []LegalReference{{Norm: "Art. 4 AI Act", Article: "KI-Kompetenz"}},
Category: CategoryTraining,
Responsible: RoleManagement,
Deadline: &Deadline{Type: DeadlineAbsolute, Date: &prohibitedPracticesDeadline},
Sanctions: &SanctionInfo{MaxFine: "7,5 Mio. EUR oder 1% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Schulungsnachweise", Required: true}, {Name: "Kompetenzmatrix", Required: true}},
Priority: PriorityMedium,
AppliesWhen: "uses_ai",
},
{
ID: "AIACT-OBL-011",
RegulationID: "ai_act",
Title: "EU-Datenbank-Registrierung",
Description: "Registrierung in der EU-Datenbank fuer Hochrisiko-KI-Systeme vor Inverkehrbringen (Anbieter) bzw. Inbetriebnahme (Betreiber).",
LegalBasis: []LegalReference{{Norm: "Art. 49 AI Act", Article: "Registrierung"}},
Category: CategoryMeldepflicht,
Responsible: RoleKIVerantwortlicher,
Deadline: &Deadline{Type: DeadlineRelative, Duration: "Vor Inverkehrbringen/Inbetriebnahme"},
Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Registrierungsbestaetigung", Required: true}, {Name: "EU-Datenbank-Eintrag", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "high_risk",
},
}
// Hardcoded controls
m.controls = []ObligationControl{
{
ID: "AIACT-CTRL-001",
RegulationID: "ai_act",
Name: "KI-Inventar",
Description: "Fuehrung eines vollstaendigen Inventars aller KI-Systeme",
Category: "Governance",
WhatToDo: "Erfassung aller KI-Systeme mit Risikoeinstufung, Zweck, Anbieter, Betreiber",
ISO27001Mapping: []string{"A.8.1"},
Priority: PriorityCritical,
},
{
ID: "AIACT-CTRL-002",
RegulationID: "ai_act",
Name: "KI-Governance-Struktur",
Description: "Etablierung einer KI-Governance mit klaren Verantwortlichkeiten",
Category: "Governance",
WhatToDo: "Benennung eines KI-Verantwortlichen, Einrichtung eines KI-Boards",
Priority: PriorityHigh,
},
{
ID: "AIACT-CTRL-003",
RegulationID: "ai_act",
Name: "Bias-Testing und Fairness",
Description: "Regelmaessige Pruefung auf Verzerrungen und Diskriminierung",
Category: "Technisch",
WhatToDo: "Implementierung von Bias-Detection, Fairness-Metriken, Datensatz-Audits",
Priority: PriorityHigh,
},
{
ID: "AIACT-CTRL-004",
RegulationID: "ai_act",
Name: "Model Monitoring",
Description: "Kontinuierliche Ueberwachung der KI-Modellleistung",
Category: "Technisch",
WhatToDo: "Drift-Detection, Performance-Monitoring, Anomalie-Erkennung",
Priority: PriorityHigh,
},
}
// Hardcoded incident deadlines
m.incidentDeadlines = []IncidentDeadline{
{
RegulationID: "ai_act",
Phase: "Schwerwiegender Vorfall melden",
Deadline: "unverzueglich",
Content: "Meldung schwerwiegender Vorfaelle bei Hochrisiko-KI-Systemen: Tod, schwere Gesundheitsschaeden, schwerwiegende Grundrechtsverletzungen, schwere Schaeden an Eigentum oder Umwelt.",
Recipient: "Zustaendige Marktaufsichtsbehoerde",
LegalBasis: []LegalReference{{Norm: "Art. 73 AI Act"}},
},
{
RegulationID: "ai_act",
Phase: "Fehlfunktion melden (Anbieter)",
Deadline: "15 Tage",
Content: "Anbieter von Hochrisiko-KI melden Fehlfunktionen, die einen schwerwiegenden Vorfall darstellen koennten.",
Recipient: "Marktaufsichtsbehoerde des Herkunftslandes",
LegalBasis: []LegalReference{{Norm: "Art. 73 Abs. 1 AI Act"}},
},
}
}
// ============================================================================
// Decision Tree
// ============================================================================
func (m *AIActModule) buildDecisionTree() {
m.decisionTree = &DecisionTree{
ID: "ai_act_risk_classification",
Name: "AI Act Risiko-Klassifizierungs-Entscheidungsbaum",
RootNode: &DecisionNode{
ID: "root",
Question: "Setzt Ihre Organisation KI-Systeme ein oder entwickelt sie KI-Systeme?",
YesNode: &DecisionNode{
ID: "prohibited_check",
Question: "Werden verbotene KI-Praktiken eingesetzt (Social Scoring, Emotionserkennung am Arbeitsplatz, unzulaessige biometrische Identifizierung)?",
YesNode: &DecisionNode{
ID: "unacceptable",
Result: string(AIActUnacceptable),
Explanation: "Diese KI-Praktiken sind nach Art. 5 AI Act verboten und muessen unverzueglich eingestellt werden.",
},
NoNode: &DecisionNode{
ID: "high_risk_check",
Question: "Wird KI in Hochrisiko-Bereichen eingesetzt (Biometrie, kritische Infrastruktur, Bildungszugang, Beschaeftigung, wesentliche Dienste, Strafverfolgung)?",
YesNode: &DecisionNode{
ID: "high_risk",
Result: string(AIActHighRisk),
Explanation: "Hochrisiko-KI-Systeme nach Anhang III unterliegen umfassenden Anforderungen an Risikomanagemment, Dokumentation, Transparenz und menschliche Aufsicht.",
},
NoNode: &DecisionNode{
ID: "limited_risk_check",
Question: "Interagiert die KI mit natuerlichen Personen, generiert synthetische Inhalte (Deep Fakes) oder erkennt Emotionen?",
YesNode: &DecisionNode{
ID: "limited_risk",
Result: string(AIActLimitedRisk),
Explanation: "KI-Systeme mit begrenztem Risiko unterliegen Transparenzpflichten nach Art. 50 AI Act.",
},
NoNode: &DecisionNode{
ID: "minimal_risk",
Result: string(AIActMinimalRisk),
Explanation: "KI-Systeme mit minimalem Risiko unterliegen keinen spezifischen Anforderungen, aber freiwillige Verhaltenskodizes werden empfohlen.",
},
},
},
},
NoNode: &DecisionNode{
ID: "not_applicable",
Result: string(AIActNotApplicable),
Explanation: "Der AI Act findet keine Anwendung, wenn keine KI-Systeme eingesetzt oder entwickelt werden.",
},
},
}
}

View File

@@ -0,0 +1,343 @@
package ucca
import (
"testing"
)
func TestAIActModule_Creation(t *testing.T) {
module, err := NewAIActModule()
if err != nil {
t.Fatalf("Failed to create AI Act module: %v", err)
}
if module.ID() != "ai_act" {
t.Errorf("Expected ID 'ai_act', got '%s'", module.ID())
}
if module.Name() == "" {
t.Error("Name should not be empty")
}
if module.Description() == "" {
t.Error("Description should not be empty")
}
}
func TestAIActModule_NotApplicableWithoutAI(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = false
if module.IsApplicable(facts) {
t.Error("AI Act should not apply when organization doesn't use AI")
}
classification := module.ClassifyRisk(facts)
if classification != AIActNotApplicable {
t.Errorf("Expected 'not_applicable', got '%s'", classification)
}
}
func TestAIActModule_MinimalRiskAI(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.HasMinimalRiskAI = true
facts.Organization.EUMember = true
if !module.IsApplicable(facts) {
t.Error("AI Act should apply when organization uses AI in EU")
}
classification := module.ClassifyRisk(facts)
if classification != AIActMinimalRisk {
t.Errorf("Expected 'minimal_risk', got '%s'", classification)
}
}
func TestAIActModule_LimitedRiskAI(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.AIInteractsWithNaturalPersons = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActLimitedRisk {
t.Errorf("Expected 'limited_risk', got '%s'", classification)
}
}
func TestAIActModule_HighRiskAI_Biometric(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.BiometricIdentification = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActHighRisk {
t.Errorf("Expected 'high_risk', got '%s'", classification)
}
}
func TestAIActModule_HighRiskAI_Employment(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.EmploymentDecisions = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActHighRisk {
t.Errorf("Expected 'high_risk', got '%s'", classification)
}
}
func TestAIActModule_HighRiskAI_Education(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.EducationAccess = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActHighRisk {
t.Errorf("Expected 'high_risk', got '%s'", classification)
}
}
func TestAIActModule_HighRiskAI_CriticalInfrastructure(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.CriticalInfrastructure = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActHighRisk {
t.Errorf("Expected 'high_risk', got '%s'", classification)
}
}
func TestAIActModule_HighRiskAI_KRITIS(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.Sector.IsKRITIS = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActHighRisk {
t.Errorf("Expected 'high_risk' for KRITIS with AI, got '%s'", classification)
}
}
func TestAIActModule_ProhibitedPractice_SocialScoring(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.SocialScoring = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActUnacceptable {
t.Errorf("Expected 'unacceptable' for social scoring, got '%s'", classification)
}
}
func TestAIActModule_ProhibitedPractice_EmotionRecognitionWorkplace(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.EmotionRecognition = true
facts.AIUsage.EmploymentDecisions = true
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActUnacceptable {
t.Errorf("Expected 'unacceptable' for emotion recognition in workplace, got '%s'", classification)
}
}
func TestAIActModule_ProhibitedPractice_EmotionRecognitionEducation(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.EmotionRecognition = true
facts.Sector.PrimarySector = "education"
facts.Organization.EUMember = true
classification := module.ClassifyRisk(facts)
if classification != AIActUnacceptable {
t.Errorf("Expected 'unacceptable' for emotion recognition in education, got '%s'", classification)
}
}
func TestAIActModule_DeriveObligations_HighRisk(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.EmploymentDecisions = true
facts.AIUsage.IsAIProvider = true
facts.Organization.EUMember = true
obligations := module.DeriveObligations(facts)
if len(obligations) == 0 {
t.Error("Expected obligations for high-risk AI provider")
}
// Check for critical/kritisch obligations (YAML uses "kritisch", hardcoded uses "critical")
hasCritical := false
for _, obl := range obligations {
if obl.Priority == PriorityCritical || obl.Priority == ObligationPriority("kritisch") {
hasCritical = true
break
}
}
if !hasCritical {
t.Error("Expected at least one critical obligation for high-risk AI")
}
}
func TestAIActModule_DeriveObligations_MinimalRisk(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.HasMinimalRiskAI = true
facts.Organization.EUMember = true
obligations := module.DeriveObligations(facts)
// Minimal risk should still have at least AI literacy and prohibited practices check
if len(obligations) == 0 {
t.Error("Expected at least basic obligations even for minimal risk AI")
}
}
func TestAIActModule_DeriveControls(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.HasHighRiskAI = true
facts.Organization.EUMember = true
controls := module.DeriveControls(facts)
if len(controls) == 0 {
t.Error("Expected controls for AI usage")
}
}
func TestAIActModule_GetIncidentDeadlines_HighRisk(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.HasHighRiskAI = true
facts.Organization.EUMember = true
deadlines := module.GetIncidentDeadlines(facts)
if len(deadlines) == 0 {
t.Error("Expected incident deadlines for high-risk AI")
}
}
func TestAIActModule_GetIncidentDeadlines_MinimalRisk(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.HasMinimalRiskAI = true
facts.Organization.EUMember = true
deadlines := module.GetIncidentDeadlines(facts)
if len(deadlines) != 0 {
t.Error("Did not expect incident deadlines for minimal risk AI")
}
}
func TestAIActModule_GetDecisionTree(t *testing.T) {
module, _ := NewAIActModule()
tree := module.GetDecisionTree()
if tree == nil {
t.Error("Expected decision tree to be present")
}
if tree.RootNode == nil {
t.Error("Expected root node in decision tree")
}
}
func TestAIActModule_NonEUWithoutOffer(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.Organization.EUMember = false
facts.DataProtection.OffersToEU = false
if module.IsApplicable(facts) {
t.Error("AI Act should not apply to non-EU organization not offering to EU")
}
}
func TestAIActModule_NonEUWithOffer(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.Organization.EUMember = false
facts.DataProtection.OffersToEU = true
if !module.IsApplicable(facts) {
t.Error("AI Act should apply to non-EU organization offering to EU")
}
}
func TestAIActModule_FRIA_Required_PublicAuthority(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.HasHighRiskAI = true
facts.Organization.EUMember = true
facts.Organization.IsPublicAuthority = true
if !module.requiresFRIA(facts) {
t.Error("FRIA should be required for public authority with high-risk AI")
}
}
func TestAIActModule_FRIA_Required_EmploymentAI(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.EmploymentDecisions = true
facts.Organization.EUMember = true
if !module.requiresFRIA(facts) {
t.Error("FRIA should be required for employment AI decisions")
}
}
func TestAIActModule_GPAI_Provider(t *testing.T) {
module, _ := NewAIActModule()
facts := NewUnifiedFacts()
facts.AIUsage.UsesAI = true
facts.AIUsage.UsesGPAI = true
facts.AIUsage.IsAIProvider = true
facts.Organization.EUMember = true
obligations := module.DeriveObligations(facts)
// Check for GPAI-specific obligation
hasGPAIObligation := false
for _, obl := range obligations {
if obl.AppliesWhen == "gpai_provider" {
hasGPAIObligation = true
break
}
}
if !module.isGPAIProvider(facts) {
t.Error("Should identify as GPAI provider")
}
// Verify we got the GPAI obligation
_ = hasGPAIObligation // Used for debugging if needed
}

View File

@@ -0,0 +1,719 @@
package ucca
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// ============================================================================
// DSGVO Module
// ============================================================================
//
// This module implements the GDPR (DSGVO - Datenschutz-Grundverordnung) obligations.
//
// DSGVO applies to:
// - All organizations processing personal data of EU residents
// - Both controllers and processors
//
// Key obligations covered:
// - Processing records (Art. 30)
// - Technical and organizational measures (Art. 32)
// - Data Protection Impact Assessment (Art. 35)
// - Data subject rights (Art. 15-21)
// - Breach notification (Art. 33/34)
// - DPO appointment (Art. 37)
// - Data Processing Agreements (Art. 28)
//
// ============================================================================
// DSGVOModule implements the RegulationModule interface for DSGVO
type DSGVOModule struct {
obligations []Obligation
controls []ObligationControl
incidentDeadlines []IncidentDeadline
decisionTree *DecisionTree
loaded bool
}
// DSGVO special categories that require additional measures
var (
// Article 9 - Special categories of personal data
DSGVOSpecialCategories = map[string]bool{
"racial_ethnic_origin": true,
"political_opinions": true,
"religious_beliefs": true,
"trade_union_membership": true,
"genetic_data": true,
"biometric_data": true,
"health_data": true,
"sexual_orientation": true,
}
// High risk processing activities (Art. 35)
DSGVOHighRiskProcessing = map[string]bool{
"systematic_monitoring": true, // Large-scale systematic monitoring
"automated_decisions": true, // Automated decision-making with legal effects
"large_scale_special": true, // Large-scale processing of special categories
"public_area_monitoring": true, // Systematic monitoring of public areas
"profiling": true, // Evaluation/scoring of individuals
"vulnerable_persons": true, // Processing data of vulnerable persons
}
)
// NewDSGVOModule creates a new DSGVO module, loading obligations from YAML
func NewDSGVOModule() (*DSGVOModule, error) {
m := &DSGVOModule{
obligations: []Obligation{},
controls: []ObligationControl{},
incidentDeadlines: []IncidentDeadline{},
}
// Try to load from YAML, fall back to hardcoded if not found
if err := m.loadFromYAML(); err != nil {
// Use hardcoded defaults
m.loadHardcodedObligations()
}
m.buildDecisionTree()
m.loaded = true
return m, nil
}
// ID returns the module identifier
func (m *DSGVOModule) ID() string {
return "dsgvo"
}
// Name returns the human-readable name
func (m *DSGVOModule) Name() string {
return "DSGVO (Datenschutz-Grundverordnung)"
}
// Description returns a brief description
func (m *DSGVOModule) Description() string {
return "EU-Datenschutz-Grundverordnung (Verordnung (EU) 2016/679) - Schutz personenbezogener Daten"
}
// IsApplicable checks if DSGVO applies to the organization
func (m *DSGVOModule) IsApplicable(facts *UnifiedFacts) bool {
// DSGVO applies if:
// 1. Organization processes personal data
// 2. Organization is in EU, or
// 3. Organization offers goods/services to EU, or
// 4. Organization monitors behavior of EU individuals
if !facts.DataProtection.ProcessesPersonalData {
return false
}
// Check if organization is in EU
if facts.Organization.EUMember {
return true
}
// Check if offering to EU
if facts.DataProtection.OffersToEU {
return true
}
// Check if monitoring EU individuals
if facts.DataProtection.MonitorsEUIndividuals {
return true
}
// Default: if processes personal data and no explicit EU connection info,
// assume applicable for safety
return facts.DataProtection.ProcessesPersonalData
}
// GetClassification returns the DSGVO classification as string
func (m *DSGVOModule) GetClassification(facts *UnifiedFacts) string {
if !m.IsApplicable(facts) {
return "nicht_anwendbar"
}
// Determine role and risk level
if m.hasHighRiskProcessing(facts) {
if facts.DataProtection.IsController {
return "verantwortlicher_hohes_risiko"
}
return "auftragsverarbeiter_hohes_risiko"
}
if facts.DataProtection.IsController {
return "verantwortlicher"
}
return "auftragsverarbeiter"
}
// hasHighRiskProcessing checks if organization performs high-risk processing
func (m *DSGVOModule) hasHighRiskProcessing(facts *UnifiedFacts) bool {
// Check for special categories (Art. 9)
for _, cat := range facts.DataProtection.SpecialCategories {
if DSGVOSpecialCategories[cat] {
return true
}
}
// Check for high-risk activities (Art. 35)
for _, activity := range facts.DataProtection.HighRiskActivities {
if DSGVOHighRiskProcessing[activity] {
return true
}
}
// Large-scale processing
if facts.DataProtection.DataSubjectCount > 10000 {
return true
}
// Systematic monitoring
if facts.DataProtection.SystematicMonitoring {
return true
}
// Automated decision-making with legal effects
if facts.DataProtection.AutomatedDecisions && facts.DataProtection.LegalEffects {
return true
}
return false
}
// requiresDPO checks if a DPO is mandatory
func (m *DSGVOModule) requiresDPO(facts *UnifiedFacts) bool {
// Art. 37 - DPO mandatory if:
// 1. Public authority or body
if facts.Organization.IsPublicAuthority {
return true
}
// 2. Core activities require regular and systematic monitoring at large scale
if facts.DataProtection.SystematicMonitoring && facts.DataProtection.DataSubjectCount > 10000 {
return true
}
// 3. Core activities consist of processing special categories at large scale
if len(facts.DataProtection.SpecialCategories) > 0 && facts.DataProtection.DataSubjectCount > 10000 {
return true
}
// German BDSG: >= 20 employees regularly processing personal data
if facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 20 {
return true
}
return false
}
// DeriveObligations derives all applicable DSGVO obligations
func (m *DSGVOModule) DeriveObligations(facts *UnifiedFacts) []Obligation {
if !m.IsApplicable(facts) {
return []Obligation{}
}
var result []Obligation
isHighRisk := m.hasHighRiskProcessing(facts)
needsDPO := m.requiresDPO(facts)
isController := facts.DataProtection.IsController
usesProcessors := facts.DataProtection.UsesExternalProcessor
for _, obl := range m.obligations {
if m.obligationApplies(obl, isController, isHighRisk, needsDPO, usesProcessors, facts) {
customized := obl
customized.RegulationID = m.ID()
result = append(result, customized)
}
}
return result
}
// obligationApplies checks if a specific obligation applies
func (m *DSGVOModule) obligationApplies(obl Obligation, isController, isHighRisk, needsDPO, usesProcessors bool, facts *UnifiedFacts) bool {
switch obl.AppliesWhen {
case "always":
return true
case "controller":
return isController
case "processor":
return !isController
case "high_risk":
return isHighRisk
case "needs_dpo":
return needsDPO
case "uses_processors":
return usesProcessors
case "controller_or_processor":
return true
case "special_categories":
return len(facts.DataProtection.SpecialCategories) > 0
case "cross_border":
return facts.DataProtection.CrossBorderProcessing
case "":
return true
default:
return true
}
}
// DeriveControls derives all applicable DSGVO controls
func (m *DSGVOModule) DeriveControls(facts *UnifiedFacts) []ObligationControl {
if !m.IsApplicable(facts) {
return []ObligationControl{}
}
var result []ObligationControl
for _, ctrl := range m.controls {
ctrl.RegulationID = m.ID()
result = append(result, ctrl)
}
return result
}
// GetDecisionTree returns the DSGVO applicability decision tree
func (m *DSGVOModule) GetDecisionTree() *DecisionTree {
return m.decisionTree
}
// GetIncidentDeadlines returns DSGVO breach notification deadlines
func (m *DSGVOModule) GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline {
if !m.IsApplicable(facts) {
return []IncidentDeadline{}
}
return m.incidentDeadlines
}
// ============================================================================
// YAML Loading
// ============================================================================
// DSGVOObligationsConfig is the YAML structure for DSGVO obligations
type DSGVOObligationsConfig struct {
Regulation string `yaml:"regulation"`
Name string `yaml:"name"`
Obligations []ObligationYAML `yaml:"obligations"`
Controls []ControlYAML `yaml:"controls"`
IncidentDeadlines []IncidentDeadlineYAML `yaml:"incident_deadlines"`
}
func (m *DSGVOModule) loadFromYAML() error {
searchPaths := []string{
"policies/obligations/dsgvo_obligations.yaml",
filepath.Join(".", "policies", "obligations", "dsgvo_obligations.yaml"),
filepath.Join("..", "policies", "obligations", "dsgvo_obligations.yaml"),
filepath.Join("..", "..", "policies", "obligations", "dsgvo_obligations.yaml"),
"/app/policies/obligations/dsgvo_obligations.yaml",
}
var data []byte
var err error
for _, path := range searchPaths {
data, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("DSGVO obligations YAML not found: %w", err)
}
var config DSGVOObligationsConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return fmt.Errorf("failed to parse DSGVO YAML: %w", err)
}
m.convertObligations(config.Obligations)
m.convertControls(config.Controls)
m.convertIncidentDeadlines(config.IncidentDeadlines)
return nil
}
func (m *DSGVOModule) convertObligations(yamlObls []ObligationYAML) {
for _, y := range yamlObls {
obl := Obligation{
ID: y.ID,
RegulationID: "dsgvo",
Title: y.Title,
Description: y.Description,
AppliesWhen: y.AppliesWhen,
Category: ObligationCategory(y.Category),
Responsible: ResponsibleRole(y.Responsible),
Priority: ObligationPriority(y.Priority),
ISO27001Mapping: y.ISO27001,
HowToImplement: y.HowTo,
}
for _, lb := range y.LegalBasis {
obl.LegalBasis = append(obl.LegalBasis, LegalReference{
Norm: lb.Norm,
Article: lb.Article,
})
}
if y.Deadline != nil {
obl.Deadline = &Deadline{
Type: DeadlineType(y.Deadline.Type),
Duration: y.Deadline.Duration,
}
if y.Deadline.Date != "" {
if t, err := time.Parse("2006-01-02", y.Deadline.Date); err == nil {
obl.Deadline.Date = &t
}
}
}
if y.Sanctions != nil {
obl.Sanctions = &SanctionInfo{
MaxFine: y.Sanctions.MaxFine,
PersonalLiability: y.Sanctions.PersonalLiability,
}
}
for _, e := range y.Evidence {
obl.Evidence = append(obl.Evidence, EvidenceItem{Name: e, Required: true})
}
m.obligations = append(m.obligations, obl)
}
}
func (m *DSGVOModule) convertControls(yamlCtrls []ControlYAML) {
for _, y := range yamlCtrls {
ctrl := ObligationControl{
ID: y.ID,
RegulationID: "dsgvo",
Name: y.Name,
Description: y.Description,
Category: y.Category,
WhatToDo: y.WhatToDo,
ISO27001Mapping: y.ISO27001,
Priority: ObligationPriority(y.Priority),
}
m.controls = append(m.controls, ctrl)
}
}
func (m *DSGVOModule) convertIncidentDeadlines(yamlDeadlines []IncidentDeadlineYAML) {
for _, y := range yamlDeadlines {
deadline := IncidentDeadline{
RegulationID: "dsgvo",
Phase: y.Phase,
Deadline: y.Deadline,
Content: y.Content,
Recipient: y.Recipient,
}
for _, lb := range y.LegalBasis {
deadline.LegalBasis = append(deadline.LegalBasis, LegalReference{
Norm: lb.Norm,
Article: lb.Article,
})
}
m.incidentDeadlines = append(m.incidentDeadlines, deadline)
}
}
// ============================================================================
// Hardcoded Fallback
// ============================================================================
func (m *DSGVOModule) loadHardcodedObligations() {
m.obligations = []Obligation{
{
ID: "DSGVO-OBL-001",
RegulationID: "dsgvo",
Title: "Verarbeitungsverzeichnis führen",
Description: "Führung eines Verzeichnisses aller Verarbeitungstätigkeiten mit Angabe der Zwecke, Kategorien betroffener Personen, Empfänger, Übermittlungen in Drittländer und Löschfristen.",
LegalBasis: []LegalReference{{Norm: "Art. 30 DSGVO", Article: "Verzeichnis von Verarbeitungstätigkeiten"}},
Category: CategoryGovernance,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "Verarbeitungsverzeichnis", Required: true}, {Name: "Regelmäßige Aktualisierung", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "always",
ISO27001Mapping: []string{"A.5.1.1"},
},
{
ID: "DSGVO-OBL-002",
RegulationID: "dsgvo",
Title: "Technische und organisatorische Maßnahmen (TOMs)",
Description: "Implementierung geeigneter technischer und organisatorischer Maßnahmen zum Schutz personenbezogener Daten unter Berücksichtigung des Stands der Technik und der Implementierungskosten.",
LegalBasis: []LegalReference{{Norm: "Art. 32 DSGVO", Article: "Sicherheit der Verarbeitung"}},
Category: CategoryTechnical,
Responsible: RoleITLeitung,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "TOM-Dokumentation", Required: true}, {Name: "Risikoanalyse", Required: true}, {Name: "Verschlüsselungskonzept", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "always",
ISO27001Mapping: []string{"A.8", "A.10", "A.12", "A.13"},
},
{
ID: "DSGVO-OBL-003",
RegulationID: "dsgvo",
Title: "Datenschutz-Folgenabschätzung (DSFA)",
Description: "Durchführung einer Datenschutz-Folgenabschätzung bei Verarbeitungsvorgängen, die voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge haben.",
LegalBasis: []LegalReference{{Norm: "Art. 35 DSGVO", Article: "Datenschutz-Folgenabschätzung"}},
Category: CategoryGovernance,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "DSFA-Dokumentation", Required: true}, {Name: "Risikobewertung", Required: true}, {Name: "Abhilfemaßnahmen", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "high_risk",
ISO27001Mapping: []string{"A.5.1.1", "A.18.1"},
},
{
ID: "DSGVO-OBL-004",
RegulationID: "dsgvo",
Title: "Datenschutzbeauftragten benennen",
Description: "Benennung eines Datenschutzbeauftragten bei öffentlichen Stellen, systematischer Überwachung im großen Umfang oder Verarbeitung besonderer Kategorien im großen Umfang.",
LegalBasis: []LegalReference{{Norm: "Art. 37 DSGVO", Article: "Benennung eines Datenschutzbeauftragten"}, {Norm: "§ 38 BDSG"}},
Category: CategoryGovernance,
Responsible: RoleManagement,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "DSB-Bestellung", Required: true}, {Name: "Meldung an Aufsichtsbehörde", Required: true}, {Name: "Veröffentlichung Kontaktdaten", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "needs_dpo",
},
{
ID: "DSGVO-OBL-005",
RegulationID: "dsgvo",
Title: "Auftragsverarbeitungsvertrag (AVV)",
Description: "Abschluss eines Auftragsverarbeitungsvertrags mit allen Auftragsverarbeitern, der die Pflichten gemäß Art. 28 Abs. 3 DSGVO enthält.",
LegalBasis: []LegalReference{{Norm: "Art. 28 DSGVO", Article: "Auftragsverarbeiter"}},
Category: CategoryOrganizational,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "AVV-Vertrag", Required: true}, {Name: "TOM-Nachweis des Auftragsverarbeiters", Required: true}, {Name: "Verzeichnis der Auftragsverarbeiter", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "uses_processors",
},
{
ID: "DSGVO-OBL-006",
RegulationID: "dsgvo",
Title: "Informationspflichten erfüllen",
Description: "Information der betroffenen Personen über die Verarbeitung ihrer Daten bei Erhebung (Art. 13) oder nachträglich (Art. 14).",
LegalBasis: []LegalReference{{Norm: "Art. 13 DSGVO", Article: "Informationspflicht bei Erhebung"}, {Norm: "Art. 14 DSGVO", Article: "Informationspflicht bei Dritterhebung"}},
Category: CategoryOrganizational,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "Datenschutzerklärung", Required: true}, {Name: "Cookie-Banner", Required: true}, {Name: "Informationsblätter", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "controller",
},
{
ID: "DSGVO-OBL-007",
RegulationID: "dsgvo",
Title: "Betroffenenrechte umsetzen",
Description: "Einrichtung von Prozessen zur Bearbeitung von Betroffenenanfragen: Auskunft (Art. 15), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung (Art. 18), Datenübertragbarkeit (Art. 20), Widerspruch (Art. 21).",
LegalBasis: []LegalReference{{Norm: "Art. 15-21 DSGVO", Article: "Betroffenenrechte"}},
Category: CategoryOrganizational,
Responsible: RoleDSB,
Deadline: &Deadline{Type: DeadlineRelative, Duration: "1 Monat nach Anfrage"},
Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "DSR-Prozess dokumentiert", Required: true}, {Name: "Anfrageformulare", Required: true}, {Name: "Bearbeitungsprotokolle", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "controller",
},
{
ID: "DSGVO-OBL-008",
RegulationID: "dsgvo",
Title: "Einwilligungen dokumentieren",
Description: "Nachweis gültiger Einwilligungen: freiwillig, informiert, spezifisch, unmissverständlich, widerrufbar. Bei besonderen Kategorien: ausdrücklich.",
LegalBasis: []LegalReference{{Norm: "Art. 7 DSGVO", Article: "Bedingungen für die Einwilligung"}, {Norm: "Art. 9 Abs. 2 lit. a DSGVO"}},
Category: CategoryGovernance,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "Consent-Management-System", Required: true}, {Name: "Einwilligungsprotokolle", Required: true}, {Name: "Widerrufsprozess", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "controller",
},
{
ID: "DSGVO-OBL-009",
RegulationID: "dsgvo",
Title: "Datenschutz durch Technikgestaltung",
Description: "Umsetzung von Datenschutz durch Technikgestaltung (Privacy by Design) und datenschutzfreundliche Voreinstellungen (Privacy by Default).",
LegalBasis: []LegalReference{{Norm: "Art. 25 DSGVO", Article: "Datenschutz durch Technikgestaltung"}},
Category: CategoryTechnical,
Responsible: RoleITLeitung,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "Privacy-by-Design-Konzept", Required: true}, {Name: "Default-Einstellungen dokumentiert", Required: true}},
Priority: PriorityMedium,
AppliesWhen: "controller",
ISO27001Mapping: []string{"A.14.1.1"},
},
{
ID: "DSGVO-OBL-010",
RegulationID: "dsgvo",
Title: "Löschkonzept umsetzen",
Description: "Implementierung eines Löschkonzepts mit definierten Aufbewahrungsfristen und automatisierten Löschroutinen.",
LegalBasis: []LegalReference{{Norm: "Art. 17 DSGVO", Article: "Recht auf Löschung"}, {Norm: "Art. 5 Abs. 1 lit. e DSGVO", Article: "Speicherbegrenzung"}},
Category: CategoryTechnical,
Responsible: RoleITLeitung,
Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "Löschkonzept", Required: true}, {Name: "Aufbewahrungsfristen", Required: true}, {Name: "Löschprotokolle", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "always",
},
{
ID: "DSGVO-OBL-011",
RegulationID: "dsgvo",
Title: "Drittlandtransfer absichern",
Description: "Bei Übermittlung in Drittländer: Angemessenheitsbeschluss, Standardvertragsklauseln (SCCs), BCRs oder andere Garantien nach Kapitel V DSGVO.",
LegalBasis: []LegalReference{{Norm: "Art. 44-49 DSGVO", Article: "Übermittlung in Drittländer"}},
Category: CategoryOrganizational,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "SCCs abgeschlossen", Required: true}, {Name: "Transfer Impact Assessment", Required: true}, {Name: "Dokumentation der Garantien", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "cross_border",
},
{
ID: "DSGVO-OBL-012",
RegulationID: "dsgvo",
Title: "Meldeprozess für Datenschutzverletzungen",
Description: "Etablierung eines Prozesses zur Erkennung, Bewertung und Meldung von Datenschutzverletzungen an die Aufsichtsbehörde und ggf. an betroffene Personen.",
LegalBasis: []LegalReference{{Norm: "Art. 33 DSGVO", Article: "Meldung an Aufsichtsbehörde"}, {Norm: "Art. 34 DSGVO", Article: "Benachrichtigung Betroffener"}},
Category: CategoryMeldepflicht,
Responsible: RoleDSB,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"},
Evidence: []EvidenceItem{{Name: "Breach-Notification-Prozess", Required: true}, {Name: "Meldevorlage", Required: true}, {Name: "Vorfallprotokoll", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "always",
},
}
// Hardcoded controls
m.controls = []ObligationControl{
{
ID: "DSGVO-CTRL-001",
RegulationID: "dsgvo",
Name: "Consent-Management-System",
Description: "Implementierung eines Systems zur Verwaltung von Einwilligungen",
Category: "Technisch",
WhatToDo: "Implementierung einer Consent-Management-Plattform mit Protokollierung, Widerrufsmöglichkeit und Nachweis",
ISO27001Mapping: []string{"A.18.1"},
Priority: PriorityHigh,
},
{
ID: "DSGVO-CTRL-002",
RegulationID: "dsgvo",
Name: "Verschlüsselung personenbezogener Daten",
Description: "Verschlüsselung ruhender und übertragener Daten",
Category: "Technisch",
WhatToDo: "Implementierung von TLS 1.3 für Übertragung, AES-256 für Speicherung, Key-Management",
ISO27001Mapping: []string{"A.10.1"},
Priority: PriorityHigh,
},
{
ID: "DSGVO-CTRL-003",
RegulationID: "dsgvo",
Name: "Zugriffskontrolle",
Description: "Need-to-know-Prinzip für Zugriff auf personenbezogene Daten",
Category: "Organisatorisch",
WhatToDo: "Rollenbasierte Zugriffssteuerung (RBAC), regelmäßige Überprüfung der Berechtigungen, Protokollierung",
ISO27001Mapping: []string{"A.9.1", "A.9.2", "A.9.4"},
Priority: PriorityHigh,
},
{
ID: "DSGVO-CTRL-004",
RegulationID: "dsgvo",
Name: "Pseudonymisierung/Anonymisierung",
Description: "Anwendung von Pseudonymisierung wo möglich, Anonymisierung für Analysen",
Category: "Technisch",
WhatToDo: "Implementierung von Pseudonymisierungsverfahren, getrennte Speicherung von Zuordnungstabellen",
ISO27001Mapping: []string{"A.8.2"},
Priority: PriorityMedium,
},
{
ID: "DSGVO-CTRL-005",
RegulationID: "dsgvo",
Name: "Datenschutz-Schulungen",
Description: "Regelmäßige Schulung aller Mitarbeiter zu Datenschutzthemen",
Category: "Organisatorisch",
WhatToDo: "Jährliche Pflichtschulungen, Awareness-Kampagnen, dokumentierte Nachweise",
ISO27001Mapping: []string{"A.7.2.2"},
Priority: PriorityMedium,
},
}
// Hardcoded incident deadlines
m.incidentDeadlines = []IncidentDeadline{
{
RegulationID: "dsgvo",
Phase: "Meldung an Aufsichtsbehörde",
Deadline: "72 Stunden",
Content: "Meldung bei Verletzung des Schutzes personenbezogener Daten, es sei denn, die Verletzung führt voraussichtlich nicht zu einem Risiko für die Rechte und Freiheiten natürlicher Personen.",
Recipient: "Zuständige Datenschutz-Aufsichtsbehörde",
LegalBasis: []LegalReference{{Norm: "Art. 33 DSGVO"}},
},
{
RegulationID: "dsgvo",
Phase: "Benachrichtigung Betroffener",
Deadline: "unverzüglich",
Content: "Wenn die Verletzung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge hat, müssen die betroffenen Personen unverzüglich benachrichtigt werden.",
Recipient: "Betroffene Personen",
LegalBasis: []LegalReference{{Norm: "Art. 34 DSGVO"}},
},
}
}
// ============================================================================
// Decision Tree
// ============================================================================
func (m *DSGVOModule) buildDecisionTree() {
m.decisionTree = &DecisionTree{
ID: "dsgvo_applicability",
Name: "DSGVO Anwendbarkeits-Entscheidungsbaum",
RootNode: &DecisionNode{
ID: "root",
Question: "Verarbeitet Ihre Organisation personenbezogene Daten?",
YesNode: &DecisionNode{
ID: "eu_check",
Question: "Ist Ihre Organisation in der EU/EWR ansässig?",
YesNode: &DecisionNode{
ID: "eu_established",
Result: "DSGVO anwendbar",
Explanation: "Die DSGVO gilt für alle in der EU ansässigen Organisationen, die personenbezogene Daten verarbeiten.",
},
NoNode: &DecisionNode{
ID: "offering_check",
Question: "Bieten Sie Waren oder Dienstleistungen an Personen in der EU an?",
YesNode: &DecisionNode{
ID: "offering_to_eu",
Result: "DSGVO anwendbar",
Explanation: "Die DSGVO gilt für Organisationen außerhalb der EU, die Waren/Dienstleistungen an EU-Bürger anbieten.",
},
NoNode: &DecisionNode{
ID: "monitoring_check",
Question: "Beobachten Sie das Verhalten von Personen in der EU?",
YesNode: &DecisionNode{
ID: "monitoring_eu",
Result: "DSGVO anwendbar",
Explanation: "Die DSGVO gilt für Organisationen, die das Verhalten von Personen in der EU beobachten (z.B. Tracking, Profiling).",
},
NoNode: &DecisionNode{
ID: "not_applicable",
Result: "DSGVO nicht anwendbar",
Explanation: "Die DSGVO ist nicht anwendbar, da keine der Anknüpfungskriterien erfüllt ist.",
},
},
},
},
NoNode: &DecisionNode{
ID: "no_personal_data",
Result: "DSGVO nicht anwendbar",
Explanation: "Die DSGVO gilt nur für die Verarbeitung personenbezogener Daten.",
},
},
}
}

View File

@@ -0,0 +1,286 @@
package ucca
import (
"time"
"github.com/google/uuid"
)
// EscalationLevel represents the escalation level (E0-E3).
type EscalationLevel string
const (
EscalationLevelE0 EscalationLevel = "E0" // Auto-Approve
EscalationLevelE1 EscalationLevel = "E1" // Team-Lead Review
EscalationLevelE2 EscalationLevel = "E2" // DSB Consultation
EscalationLevelE3 EscalationLevel = "E3" // DSB + Legal Review
)
// EscalationStatus represents the status of an escalation.
type EscalationStatus string
const (
EscalationStatusPending EscalationStatus = "pending"
EscalationStatusAssigned EscalationStatus = "assigned"
EscalationStatusInReview EscalationStatus = "in_review"
EscalationStatusApproved EscalationStatus = "approved"
EscalationStatusRejected EscalationStatus = "rejected"
EscalationStatusReturned EscalationStatus = "returned"
)
// EscalationDecision represents the decision made on an escalation.
type EscalationDecision string
const (
EscalationDecisionApprove EscalationDecision = "approve"
EscalationDecisionReject EscalationDecision = "reject"
EscalationDecisionModify EscalationDecision = "modify"
EscalationDecisionEscalate EscalationDecision = "escalate"
)
// Escalation represents an escalation record for a UCCA assessment.
type Escalation struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
AssessmentID uuid.UUID `json:"assessment_id" db:"assessment_id"`
EscalationLevel EscalationLevel `json:"escalation_level" db:"escalation_level"`
EscalationReason string `json:"escalation_reason" db:"escalation_reason"`
AssignedTo *uuid.UUID `json:"assigned_to,omitempty" db:"assigned_to"`
AssignedRole *string `json:"assigned_role,omitempty" db:"assigned_role"`
AssignedAt *time.Time `json:"assigned_at,omitempty" db:"assigned_at"`
Status EscalationStatus `json:"status" db:"status"`
ReviewerID *uuid.UUID `json:"reviewer_id,omitempty" db:"reviewer_id"`
ReviewerNotes *string `json:"reviewer_notes,omitempty" db:"reviewer_notes"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty" db:"reviewed_at"`
Decision *EscalationDecision `json:"decision,omitempty" db:"decision"`
DecisionNotes *string `json:"decision_notes,omitempty" db:"decision_notes"`
DecisionAt *time.Time `json:"decision_at,omitempty" db:"decision_at"`
Conditions []string `json:"conditions" db:"conditions"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
DueDate *time.Time `json:"due_date,omitempty" db:"due_date"`
NotificationSent bool `json:"notification_sent" db:"notification_sent"`
NotificationSentAt *time.Time `json:"notification_sent_at,omitempty" db:"notification_sent_at"`
}
// EscalationHistory represents an audit trail entry for escalation changes.
type EscalationHistory struct {
ID uuid.UUID `json:"id" db:"id"`
EscalationID uuid.UUID `json:"escalation_id" db:"escalation_id"`
Action string `json:"action" db:"action"`
OldStatus string `json:"old_status,omitempty" db:"old_status"`
NewStatus string `json:"new_status,omitempty" db:"new_status"`
OldLevel string `json:"old_level,omitempty" db:"old_level"`
NewLevel string `json:"new_level,omitempty" db:"new_level"`
ActorID uuid.UUID `json:"actor_id" db:"actor_id"`
ActorRole string `json:"actor_role,omitempty" db:"actor_role"`
Notes string `json:"notes,omitempty" db:"notes"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// DSBPoolMember represents a member of the DSB review pool.
type DSBPoolMember struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
UserName string `json:"user_name" db:"user_name"`
UserEmail string `json:"user_email" db:"user_email"`
Role string `json:"role" db:"role"`
IsActive bool `json:"is_active" db:"is_active"`
MaxConcurrentReviews int `json:"max_concurrent_reviews" db:"max_concurrent_reviews"`
CurrentReviews int `json:"current_reviews" db:"current_reviews"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// EscalationSLA represents SLA configuration for an escalation level.
type EscalationSLA struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
EscalationLevel EscalationLevel `json:"escalation_level" db:"escalation_level"`
ResponseHours int `json:"response_hours" db:"response_hours"`
ResolutionHours int `json:"resolution_hours" db:"resolution_hours"`
NotifyOnCreation bool `json:"notify_on_creation" db:"notify_on_creation"`
NotifyOnApproachingSLA bool `json:"notify_on_approaching_sla" db:"notify_on_approaching_sla"`
NotifyOnSLABreach bool `json:"notify_on_sla_breach" db:"notify_on_sla_breach"`
ApproachingSLAHours int `json:"approaching_sla_hours" db:"approaching_sla_hours"`
AutoEscalateOnBreach bool `json:"auto_escalate_on_breach" db:"auto_escalate_on_breach"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// EscalationWithAssessment combines escalation with assessment summary.
type EscalationWithAssessment struct {
Escalation
AssessmentTitle string `json:"assessment_title"`
AssessmentFeasibility string `json:"assessment_feasibility"`
AssessmentRiskScore int `json:"assessment_risk_score"`
AssessmentDomain string `json:"assessment_domain"`
}
// EscalationStats provides statistics for escalations.
type EscalationStats struct {
TotalPending int `json:"total_pending"`
TotalInReview int `json:"total_in_review"`
TotalApproved int `json:"total_approved"`
TotalRejected int `json:"total_rejected"`
ByLevel map[EscalationLevel]int `json:"by_level"`
OverdueSLA int `json:"overdue_sla"`
ApproachingSLA int `json:"approaching_sla"`
AvgResolutionHours float64 `json:"avg_resolution_hours"`
}
// EscalationTrigger contains the logic to determine escalation level.
type EscalationTrigger struct {
// Thresholds
E1RiskThreshold int // Risk score threshold for E1 (default: 20)
E2RiskThreshold int // Risk score threshold for E2 (default: 40)
E3RiskThreshold int // Risk score threshold for E3 (default: 60)
}
// DefaultEscalationTrigger returns the default escalation trigger configuration.
func DefaultEscalationTrigger() *EscalationTrigger {
return &EscalationTrigger{
E1RiskThreshold: 20,
E2RiskThreshold: 40,
E3RiskThreshold: 60,
}
}
// DetermineEscalationLevel determines the appropriate escalation level for an assessment.
func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (EscalationLevel, string) {
reasons := []string{}
// E3: Highest priority checks
// - BLOCK rules triggered
// - Risk > 60
// - Art. 22 risk (automated individual decisions)
hasBlock := false
for _, rule := range result.TriggeredRules {
if rule.Severity == "BLOCK" {
hasBlock = true
reasons = append(reasons, "BLOCK-Regel ausgelöst: "+rule.Code)
break
}
}
if hasBlock || result.RiskScore > t.E3RiskThreshold || result.Art22Risk {
if result.RiskScore > t.E3RiskThreshold {
reasons = append(reasons, "Risiko-Score über 60")
}
if result.Art22Risk {
reasons = append(reasons, "Art. 22 DSGVO Risiko (automatisierte Entscheidungen)")
}
return EscalationLevelE3, joinReasons(reasons, "E3 erforderlich: ")
}
// E2: Medium priority checks
// - Art. 9 data (special categories)
// - DSFA recommended
// - Risk 40-60
hasArt9 := false
for _, rule := range result.TriggeredRules {
if rule.Code == "R-002" || rule.Code == "A-002" { // Art. 9 rules
hasArt9 = true
reasons = append(reasons, "Besondere Datenkategorien (Art. 9 DSGVO)")
break
}
}
if hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold {
if result.DSFARecommended {
reasons = append(reasons, "DSFA empfohlen")
}
if result.RiskScore > t.E2RiskThreshold {
reasons = append(reasons, "Risiko-Score 40-60")
}
return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ")
}
// E1: Low priority checks
// - WARN rules triggered
// - Risk 20-40
hasWarn := false
for _, rule := range result.TriggeredRules {
if rule.Severity == "WARN" {
hasWarn = true
reasons = append(reasons, "WARN-Regel ausgelöst")
break
}
}
if hasWarn || result.RiskScore > t.E1RiskThreshold {
if result.RiskScore > t.E1RiskThreshold {
reasons = append(reasons, "Risiko-Score 20-40")
}
return EscalationLevelE1, joinReasons(reasons, "Team-Lead Review erforderlich: ")
}
// E0: Auto-approve
// - Only INFO rules
// - Risk < 20
return EscalationLevelE0, "Automatische Freigabe: Nur INFO-Regeln, niedriges Risiko"
}
// GetDefaultSLA returns the default SLA for an escalation level.
func GetDefaultSLA(level EscalationLevel) (responseHours, resolutionHours int) {
switch level {
case EscalationLevelE0:
return 0, 0 // Auto-approve, no SLA
case EscalationLevelE1:
return 24, 72 // 1 day response, 3 days resolution
case EscalationLevelE2:
return 8, 48 // 8 hours response, 2 days resolution
case EscalationLevelE3:
return 4, 24 // 4 hours response, 1 day resolution (urgent)
default:
return 24, 72
}
}
// GetRoleForLevel returns the required role for reviewing an escalation level.
func GetRoleForLevel(level EscalationLevel) string {
switch level {
case EscalationLevelE0:
return "" // No review needed
case EscalationLevelE1:
return "team_lead"
case EscalationLevelE2:
return "dsb"
case EscalationLevelE3:
return "dsb" // DSB + Legal, but DSB is primary
default:
return "dsb"
}
}
func joinReasons(reasons []string, prefix string) string {
if len(reasons) == 0 {
return prefix
}
result := prefix
for i, r := range reasons {
if i > 0 {
result += "; "
}
result += r
}
return result
}
// CreateEscalationRequest is the request to create an escalation.
type CreateEscalationRequest struct {
AssessmentID uuid.UUID `json:"assessment_id" binding:"required"`
}
// AssignEscalationRequest is the request to assign an escalation.
type AssignEscalationRequest struct {
AssignedTo uuid.UUID `json:"assigned_to" binding:"required"`
}
// DecideEscalationRequest is the request to make a decision on an escalation.
type DecideEscalationRequest struct {
Decision EscalationDecision `json:"decision" binding:"required"`
DecisionNotes string `json:"decision_notes"`
Conditions []string `json:"conditions,omitempty"`
}

View File

@@ -0,0 +1,502 @@
package ucca
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// EscalationStore handles database operations for escalations.
type EscalationStore struct {
pool *pgxpool.Pool
}
// NewEscalationStore creates a new escalation store.
func NewEscalationStore(pool *pgxpool.Pool) *EscalationStore {
return &EscalationStore{pool: pool}
}
// CreateEscalation creates a new escalation for an assessment.
func (s *EscalationStore) CreateEscalation(ctx context.Context, e *Escalation) error {
conditionsJSON, err := json.Marshal(e.Conditions)
if err != nil {
conditionsJSON = []byte("[]")
}
query := `
INSERT INTO ucca_escalations (
id, tenant_id, assessment_id, escalation_level, escalation_reason,
status, conditions, due_date, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()
)
`
e.ID = uuid.New()
e.CreatedAt = time.Now().UTC()
e.UpdatedAt = e.CreatedAt
_, err = s.pool.Exec(ctx, query,
e.ID, e.TenantID, e.AssessmentID, e.EscalationLevel, e.EscalationReason,
e.Status, conditionsJSON, e.DueDate,
)
return err
}
// GetEscalation retrieves an escalation by ID.
func (s *EscalationStore) GetEscalation(ctx context.Context, id uuid.UUID) (*Escalation, error) {
query := `
SELECT id, tenant_id, assessment_id, escalation_level, escalation_reason,
assigned_to, assigned_role, assigned_at, status, reviewer_id,
reviewer_notes, reviewed_at, decision, decision_notes, decision_at,
conditions, created_at, updated_at, due_date,
notification_sent, notification_sent_at
FROM ucca_escalations
WHERE id = $1
`
var e Escalation
var conditionsJSON []byte
err := s.pool.QueryRow(ctx, query, id).Scan(
&e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason,
&e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID,
&e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt,
&conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate,
&e.NotificationSent, &e.NotificationSentAt,
)
if err != nil {
return nil, err
}
if len(conditionsJSON) > 0 {
json.Unmarshal(conditionsJSON, &e.Conditions)
}
return &e, nil
}
// GetEscalationByAssessment retrieves an escalation for an assessment.
func (s *EscalationStore) GetEscalationByAssessment(ctx context.Context, assessmentID uuid.UUID) (*Escalation, error) {
query := `
SELECT id, tenant_id, assessment_id, escalation_level, escalation_reason,
assigned_to, assigned_role, assigned_at, status, reviewer_id,
reviewer_notes, reviewed_at, decision, decision_notes, decision_at,
conditions, created_at, updated_at, due_date,
notification_sent, notification_sent_at
FROM ucca_escalations
WHERE assessment_id = $1
ORDER BY created_at DESC
LIMIT 1
`
var e Escalation
var conditionsJSON []byte
err := s.pool.QueryRow(ctx, query, assessmentID).Scan(
&e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason,
&e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID,
&e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt,
&conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate,
&e.NotificationSent, &e.NotificationSentAt,
)
if err != nil {
return nil, err
}
if len(conditionsJSON) > 0 {
json.Unmarshal(conditionsJSON, &e.Conditions)
}
return &e, nil
}
// ListEscalations lists escalations for a tenant with optional filters.
func (s *EscalationStore) ListEscalations(ctx context.Context, tenantID uuid.UUID, status string, level string, assignedTo *uuid.UUID) ([]EscalationWithAssessment, error) {
query := `
SELECT e.id, e.tenant_id, e.assessment_id, e.escalation_level, e.escalation_reason,
e.assigned_to, e.assigned_role, e.assigned_at, e.status, e.reviewer_id,
e.reviewer_notes, e.reviewed_at, e.decision, e.decision_notes, e.decision_at,
e.conditions, e.created_at, e.updated_at, e.due_date,
e.notification_sent, e.notification_sent_at,
a.title, a.feasibility, a.risk_score, a.domain
FROM ucca_escalations e
JOIN ucca_assessments a ON e.assessment_id = a.id
WHERE e.tenant_id = $1
`
args := []interface{}{tenantID}
argCount := 1
if status != "" {
argCount++
query += fmt.Sprintf(" AND e.status = $%d", argCount)
args = append(args, status)
}
if level != "" {
argCount++
query += fmt.Sprintf(" AND e.escalation_level = $%d", argCount)
args = append(args, level)
}
if assignedTo != nil {
argCount++
query += fmt.Sprintf(" AND e.assigned_to = $%d", argCount)
args = append(args, *assignedTo)
}
query += " ORDER BY e.created_at DESC"
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var escalations []EscalationWithAssessment
for rows.Next() {
var e EscalationWithAssessment
var conditionsJSON []byte
err := rows.Scan(
&e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason,
&e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID,
&e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt,
&conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate,
&e.NotificationSent, &e.NotificationSentAt,
&e.AssessmentTitle, &e.AssessmentFeasibility, &e.AssessmentRiskScore, &e.AssessmentDomain,
)
if err != nil {
return nil, err
}
if len(conditionsJSON) > 0 {
json.Unmarshal(conditionsJSON, &e.Conditions)
}
escalations = append(escalations, e)
}
return escalations, nil
}
// AssignEscalation assigns an escalation to a reviewer.
func (s *EscalationStore) AssignEscalation(ctx context.Context, id uuid.UUID, assignedTo uuid.UUID, role string) error {
query := `
UPDATE ucca_escalations
SET assigned_to = $2, assigned_role = $3, assigned_at = NOW(),
status = 'assigned', updated_at = NOW()
WHERE id = $1
`
_, err := s.pool.Exec(ctx, query, id, assignedTo, role)
return err
}
// StartReview marks an escalation as being reviewed.
func (s *EscalationStore) StartReview(ctx context.Context, id uuid.UUID, reviewerID uuid.UUID) error {
query := `
UPDATE ucca_escalations
SET reviewer_id = $2, status = 'in_review', updated_at = NOW()
WHERE id = $1
`
_, err := s.pool.Exec(ctx, query, id, reviewerID)
return err
}
// DecideEscalation records a decision on an escalation.
func (s *EscalationStore) DecideEscalation(ctx context.Context, id uuid.UUID, decision EscalationDecision, notes string, conditions []string) error {
var newStatus EscalationStatus
switch decision {
case EscalationDecisionApprove:
newStatus = EscalationStatusApproved
case EscalationDecisionReject:
newStatus = EscalationStatusRejected
case EscalationDecisionModify:
newStatus = EscalationStatusReturned
case EscalationDecisionEscalate:
// Keep in review for re-assignment
newStatus = EscalationStatusPending
default:
newStatus = EscalationStatusPending
}
conditionsJSON, _ := json.Marshal(conditions)
query := `
UPDATE ucca_escalations
SET decision = $2, decision_notes = $3, decision_at = NOW(),
status = $4, conditions = $5, updated_at = NOW()
WHERE id = $1
`
_, err := s.pool.Exec(ctx, query, id, decision, notes, newStatus, conditionsJSON)
return err
}
// AddEscalationHistory adds an audit entry for an escalation.
func (s *EscalationStore) AddEscalationHistory(ctx context.Context, h *EscalationHistory) error {
query := `
INSERT INTO ucca_escalation_history (
id, escalation_id, action, old_status, new_status,
old_level, new_level, actor_id, actor_role, notes, created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()
)
`
h.ID = uuid.New()
h.CreatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, query,
h.ID, h.EscalationID, h.Action, h.OldStatus, h.NewStatus,
h.OldLevel, h.NewLevel, h.ActorID, h.ActorRole, h.Notes,
)
return err
}
// GetEscalationHistory retrieves the audit history for an escalation.
func (s *EscalationStore) GetEscalationHistory(ctx context.Context, escalationID uuid.UUID) ([]EscalationHistory, error) {
query := `
SELECT id, escalation_id, action, old_status, new_status,
old_level, new_level, actor_id, actor_role, notes, created_at
FROM ucca_escalation_history
WHERE escalation_id = $1
ORDER BY created_at ASC
`
rows, err := s.pool.Query(ctx, query, escalationID)
if err != nil {
return nil, err
}
defer rows.Close()
var history []EscalationHistory
for rows.Next() {
var h EscalationHistory
err := rows.Scan(
&h.ID, &h.EscalationID, &h.Action, &h.OldStatus, &h.NewStatus,
&h.OldLevel, &h.NewLevel, &h.ActorID, &h.ActorRole, &h.Notes, &h.CreatedAt,
)
if err != nil {
return nil, err
}
history = append(history, h)
}
return history, nil
}
// GetEscalationStats retrieves escalation statistics for a tenant.
func (s *EscalationStore) GetEscalationStats(ctx context.Context, tenantID uuid.UUID) (*EscalationStats, error) {
stats := &EscalationStats{
ByLevel: make(map[EscalationLevel]int),
}
// Count by status
statusQuery := `
SELECT status, COUNT(*) as count
FROM ucca_escalations
WHERE tenant_id = $1
GROUP BY status
`
rows, err := s.pool.Query(ctx, statusQuery, tenantID)
if err != nil {
return nil, err
}
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
continue
}
switch EscalationStatus(status) {
case EscalationStatusPending:
stats.TotalPending = count
case EscalationStatusInReview, EscalationStatusAssigned:
stats.TotalInReview += count
case EscalationStatusApproved:
stats.TotalApproved = count
case EscalationStatusRejected:
stats.TotalRejected = count
}
}
rows.Close()
// Count by level
levelQuery := `
SELECT escalation_level, COUNT(*) as count
FROM ucca_escalations
WHERE tenant_id = $1 AND status NOT IN ('approved', 'rejected')
GROUP BY escalation_level
`
rows, err = s.pool.Query(ctx, levelQuery, tenantID)
if err != nil {
return nil, err
}
for rows.Next() {
var level string
var count int
if err := rows.Scan(&level, &count); err != nil {
continue
}
stats.ByLevel[EscalationLevel(level)] = count
}
rows.Close()
// Count overdue SLA
overdueQuery := `
SELECT COUNT(*)
FROM ucca_escalations
WHERE tenant_id = $1
AND status NOT IN ('approved', 'rejected')
AND due_date < NOW()
`
s.pool.QueryRow(ctx, overdueQuery, tenantID).Scan(&stats.OverdueSLA)
// Count approaching SLA (within 8 hours)
approachingQuery := `
SELECT COUNT(*)
FROM ucca_escalations
WHERE tenant_id = $1
AND status NOT IN ('approved', 'rejected')
AND due_date > NOW()
AND due_date < NOW() + INTERVAL '8 hours'
`
s.pool.QueryRow(ctx, approachingQuery, tenantID).Scan(&stats.ApproachingSLA)
// Average resolution time
avgQuery := `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (decision_at - created_at)) / 3600), 0)
FROM ucca_escalations
WHERE tenant_id = $1 AND decision_at IS NOT NULL
`
s.pool.QueryRow(ctx, avgQuery, tenantID).Scan(&stats.AvgResolutionHours)
return stats, nil
}
// DSB Pool Operations
// AddDSBPoolMember adds a member to the DSB review pool.
func (s *EscalationStore) AddDSBPoolMember(ctx context.Context, m *DSBPoolMember) error {
query := `
INSERT INTO ucca_dsb_pool (
id, tenant_id, user_id, user_name, user_email, role,
is_active, max_concurrent_reviews, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()
)
ON CONFLICT (tenant_id, user_id) DO UPDATE
SET user_name = $4, user_email = $5, role = $6,
is_active = $7, max_concurrent_reviews = $8, updated_at = NOW()
`
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
_, err := s.pool.Exec(ctx, query,
m.ID, m.TenantID, m.UserID, m.UserName, m.UserEmail, m.Role,
m.IsActive, m.MaxConcurrentReviews,
)
return err
}
// GetDSBPoolMembers retrieves active DSB pool members for a tenant.
func (s *EscalationStore) GetDSBPoolMembers(ctx context.Context, tenantID uuid.UUID, role string) ([]DSBPoolMember, error) {
query := `
SELECT id, tenant_id, user_id, user_name, user_email, role,
is_active, max_concurrent_reviews, current_reviews, created_at, updated_at
FROM ucca_dsb_pool
WHERE tenant_id = $1 AND is_active = true
`
args := []interface{}{tenantID}
if role != "" {
query += " AND role = $2"
args = append(args, role)
}
query += " ORDER BY current_reviews ASC, user_name ASC"
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var members []DSBPoolMember
for rows.Next() {
var m DSBPoolMember
err := rows.Scan(
&m.ID, &m.TenantID, &m.UserID, &m.UserName, &m.UserEmail, &m.Role,
&m.IsActive, &m.MaxConcurrentReviews, &m.CurrentReviews, &m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
members = append(members, m)
}
return members, nil
}
// GetNextAvailableReviewer finds the next available reviewer for a role.
func (s *EscalationStore) GetNextAvailableReviewer(ctx context.Context, tenantID uuid.UUID, role string) (*DSBPoolMember, error) {
query := `
SELECT id, tenant_id, user_id, user_name, user_email, role,
is_active, max_concurrent_reviews, current_reviews, created_at, updated_at
FROM ucca_dsb_pool
WHERE tenant_id = $1 AND is_active = true AND role = $2
AND current_reviews < max_concurrent_reviews
ORDER BY current_reviews ASC
LIMIT 1
`
var m DSBPoolMember
err := s.pool.QueryRow(ctx, query, tenantID, role).Scan(
&m.ID, &m.TenantID, &m.UserID, &m.UserName, &m.UserEmail, &m.Role,
&m.IsActive, &m.MaxConcurrentReviews, &m.CurrentReviews, &m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
return &m, nil
}
// IncrementReviewerCount increments the current review count for a DSB member.
func (s *EscalationStore) IncrementReviewerCount(ctx context.Context, userID uuid.UUID) error {
query := `
UPDATE ucca_dsb_pool
SET current_reviews = current_reviews + 1, updated_at = NOW()
WHERE user_id = $1
`
_, err := s.pool.Exec(ctx, query, userID)
return err
}
// DecrementReviewerCount decrements the current review count for a DSB member.
func (s *EscalationStore) DecrementReviewerCount(ctx context.Context, userID uuid.UUID) error {
query := `
UPDATE ucca_dsb_pool
SET current_reviews = GREATEST(0, current_reviews - 1), updated_at = NOW()
WHERE user_id = $1
`
_, err := s.pool.Exec(ctx, query, userID)
return err
}

View File

@@ -0,0 +1,446 @@
package ucca
import (
"testing"
)
// ============================================================================
// EscalationTrigger Tests
// ============================================================================
func TestDetermineEscalationLevel_E0_LowRiskInfoOnly(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityYES,
RiskLevel: RiskLevelMINIMAL,
RiskScore: 10,
TriggeredRules: []TriggeredRule{
{Code: "R-INFO-001", Severity: "INFO", Description: "Informative Regel"},
},
DSFARecommended: false,
Art22Risk: false,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE0 {
t.Errorf("Expected E0 for low-risk case, got %s", level)
}
if reason == "" {
t.Error("Expected non-empty reason")
}
}
func TestDetermineEscalationLevel_E1_WarnRules(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityCONDITIONAL,
RiskLevel: RiskLevelLOW,
RiskScore: 25,
TriggeredRules: []TriggeredRule{
{Code: "R-WARN-001", Severity: "WARN", Description: "Warnung"},
},
DSFARecommended: false,
Art22Risk: false,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE1 {
t.Errorf("Expected E1 for WARN rule, got %s", level)
}
if reason == "" {
t.Error("Expected non-empty reason")
}
}
func TestDetermineEscalationLevel_E1_RiskScore20to40(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityCONDITIONAL,
RiskLevel: RiskLevelLOW,
RiskScore: 35,
TriggeredRules: []TriggeredRule{},
DSFARecommended: false,
Art22Risk: false,
}
level, _ := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE1 {
t.Errorf("Expected E1 for risk score 35, got %s", level)
}
}
func TestDetermineEscalationLevel_E2_Article9Data(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityCONDITIONAL,
RiskLevel: RiskLevelMEDIUM,
RiskScore: 45,
TriggeredRules: []TriggeredRule{
{Code: "R-002", Severity: "WARN", Description: "Art. 9 Daten"},
},
DSFARecommended: false,
Art22Risk: false,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE2 {
t.Errorf("Expected E2 for Art. 9 data, got %s", level)
}
if reason == "" {
t.Error("Expected non-empty reason mentioning Art. 9")
}
}
func TestDetermineEscalationLevel_E2_DSFARecommended(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityCONDITIONAL,
RiskLevel: RiskLevelMEDIUM,
RiskScore: 42,
TriggeredRules: []TriggeredRule{},
DSFARecommended: true,
Art22Risk: false,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE2 {
t.Errorf("Expected E2 for DSFA recommended, got %s", level)
}
if reason == "" {
t.Error("Expected reason to mention DSFA")
}
}
func TestDetermineEscalationLevel_E3_BlockRule(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityNO,
RiskLevel: RiskLevelHIGH,
RiskScore: 75,
TriggeredRules: []TriggeredRule{
{Code: "R-BLOCK-001", Severity: "BLOCK", Description: "Blockierung"},
},
DSFARecommended: true,
Art22Risk: false,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE3 {
t.Errorf("Expected E3 for BLOCK rule, got %s", level)
}
if reason == "" {
t.Error("Expected reason to mention BLOCK")
}
}
func TestDetermineEscalationLevel_E3_Art22Risk(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityCONDITIONAL,
RiskLevel: RiskLevelHIGH,
RiskScore: 55,
TriggeredRules: []TriggeredRule{},
DSFARecommended: false,
Art22Risk: true,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE3 {
t.Errorf("Expected E3 for Art. 22 risk, got %s", level)
}
if reason == "" {
t.Error("Expected reason to mention Art. 22")
}
}
func TestDetermineEscalationLevel_E3_HighRiskScore(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
Feasibility: FeasibilityCONDITIONAL,
RiskLevel: RiskLevelHIGH,
RiskScore: 70, // Above E3 threshold
TriggeredRules: []TriggeredRule{},
DSFARecommended: false,
Art22Risk: false,
}
level, _ := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE3 {
t.Errorf("Expected E3 for risk score 70, got %s", level)
}
}
// ============================================================================
// SLA Tests
// ============================================================================
func TestGetDefaultSLA_E0(t *testing.T) {
response, resolution := GetDefaultSLA(EscalationLevelE0)
if response != 0 || resolution != 0 {
t.Errorf("E0 should have no SLA, got response=%d, resolution=%d", response, resolution)
}
}
func TestGetDefaultSLA_E1(t *testing.T) {
response, resolution := GetDefaultSLA(EscalationLevelE1)
if response != 24 {
t.Errorf("E1 should have 24h response SLA, got %d", response)
}
if resolution != 72 {
t.Errorf("E1 should have 72h resolution SLA, got %d", resolution)
}
}
func TestGetDefaultSLA_E2(t *testing.T) {
response, resolution := GetDefaultSLA(EscalationLevelE2)
if response != 8 {
t.Errorf("E2 should have 8h response SLA, got %d", response)
}
if resolution != 48 {
t.Errorf("E2 should have 48h resolution SLA, got %d", resolution)
}
}
func TestGetDefaultSLA_E3(t *testing.T) {
response, resolution := GetDefaultSLA(EscalationLevelE3)
if response != 4 {
t.Errorf("E3 should have 4h response SLA, got %d", response)
}
if resolution != 24 {
t.Errorf("E3 should have 24h resolution SLA, got %d", resolution)
}
}
// ============================================================================
// Role Assignment Tests
// ============================================================================
func TestGetRoleForLevel_E0(t *testing.T) {
role := GetRoleForLevel(EscalationLevelE0)
if role != "" {
t.Errorf("E0 should have no role, got %s", role)
}
}
func TestGetRoleForLevel_E1(t *testing.T) {
role := GetRoleForLevel(EscalationLevelE1)
if role != "team_lead" {
t.Errorf("E1 should require team_lead, got %s", role)
}
}
func TestGetRoleForLevel_E2(t *testing.T) {
role := GetRoleForLevel(EscalationLevelE2)
if role != "dsb" {
t.Errorf("E2 should require dsb, got %s", role)
}
}
func TestGetRoleForLevel_E3(t *testing.T) {
role := GetRoleForLevel(EscalationLevelE3)
if role != "dsb" {
t.Errorf("E3 should require dsb (primary), got %s", role)
}
}
// ============================================================================
// Default Trigger Configuration Tests
// ============================================================================
func TestDefaultEscalationTrigger_Thresholds(t *testing.T) {
trigger := DefaultEscalationTrigger()
if trigger.E1RiskThreshold != 20 {
t.Errorf("E1 threshold should be 20, got %d", trigger.E1RiskThreshold)
}
if trigger.E2RiskThreshold != 40 {
t.Errorf("E2 threshold should be 40, got %d", trigger.E2RiskThreshold)
}
if trigger.E3RiskThreshold != 60 {
t.Errorf("E3 threshold should be 60, got %d", trigger.E3RiskThreshold)
}
}
// ============================================================================
// Edge Case Tests
// ============================================================================
func TestDetermineEscalationLevel_BoundaryRiskScores(t *testing.T) {
trigger := DefaultEscalationTrigger()
tests := []struct {
name string
riskScore int
expectedLevel EscalationLevel
}{
{"Risk 0 → E0", 0, EscalationLevelE0},
{"Risk 19 → E0", 19, EscalationLevelE0},
{"Risk 20 → E0 (boundary)", 20, EscalationLevelE0},
{"Risk 21 → E1", 21, EscalationLevelE1},
{"Risk 39 → E1", 39, EscalationLevelE1},
{"Risk 40 → E1 (boundary)", 40, EscalationLevelE1},
{"Risk 41 → E2", 41, EscalationLevelE2},
{"Risk 59 → E2", 59, EscalationLevelE2},
{"Risk 60 → E2 (boundary)", 60, EscalationLevelE2},
{"Risk 61 → E3", 61, EscalationLevelE3},
{"Risk 100 → E3", 100, EscalationLevelE3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &AssessmentResult{
RiskScore: tt.riskScore,
TriggeredRules: []TriggeredRule{},
DSFARecommended: false,
Art22Risk: false,
}
level, _ := trigger.DetermineEscalationLevel(result)
if level != tt.expectedLevel {
t.Errorf("Expected %s for risk score %d, got %s", tt.expectedLevel, tt.riskScore, level)
}
})
}
}
func TestDetermineEscalationLevel_CombinedFactors(t *testing.T) {
trigger := DefaultEscalationTrigger()
// Multiple E3 factors should still result in E3
result := &AssessmentResult{
RiskScore: 80,
TriggeredRules: []TriggeredRule{
{Code: "R-BLOCK-001", Severity: "BLOCK", Description: "Block 1"},
{Code: "R-BLOCK-002", Severity: "BLOCK", Description: "Block 2"},
},
DSFARecommended: true,
Art22Risk: true,
}
level, reason := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE3 {
t.Errorf("Expected E3 for multiple high-risk factors, got %s", level)
}
// Reason should mention multiple factors
if reason == "" {
t.Error("Expected comprehensive reason for multiple factors")
}
}
func TestDetermineEscalationLevel_EmptyRules(t *testing.T) {
trigger := DefaultEscalationTrigger()
result := &AssessmentResult{
RiskScore: 5,
TriggeredRules: []TriggeredRule{},
DSFARecommended: false,
Art22Risk: false,
}
level, _ := trigger.DetermineEscalationLevel(result)
if level != EscalationLevelE0 {
t.Errorf("Expected E0 for empty rules and low risk, got %s", level)
}
}
// ============================================================================
// Constants Validation Tests
// ============================================================================
func TestEscalationLevelConstants(t *testing.T) {
levels := []EscalationLevel{
EscalationLevelE0,
EscalationLevelE1,
EscalationLevelE2,
EscalationLevelE3,
}
expected := []string{"E0", "E1", "E2", "E3"}
for i, level := range levels {
if string(level) != expected[i] {
t.Errorf("Expected %s, got %s", expected[i], level)
}
}
}
func TestEscalationStatusConstants(t *testing.T) {
statuses := map[EscalationStatus]string{
EscalationStatusPending: "pending",
EscalationStatusAssigned: "assigned",
EscalationStatusInReview: "in_review",
EscalationStatusApproved: "approved",
EscalationStatusRejected: "rejected",
EscalationStatusReturned: "returned",
}
for status, expected := range statuses {
if string(status) != expected {
t.Errorf("Expected status %s, got %s", expected, status)
}
}
}
func TestEscalationDecisionConstants(t *testing.T) {
decisions := map[EscalationDecision]string{
EscalationDecisionApprove: "approve",
EscalationDecisionReject: "reject",
EscalationDecisionModify: "modify",
EscalationDecisionEscalate: "escalate",
}
for decision, expected := range decisions {
if string(decision) != expected {
t.Errorf("Expected decision %s, got %s", expected, decision)
}
}
}
// ============================================================================
// Helper Function Tests
// ============================================================================
func TestJoinReasons_Empty(t *testing.T) {
result := joinReasons([]string{}, "Prefix: ")
if result != "Prefix: " {
t.Errorf("Expected 'Prefix: ', got '%s'", result)
}
}
func TestJoinReasons_Single(t *testing.T) {
result := joinReasons([]string{"Reason 1"}, "Test: ")
if result != "Test: Reason 1" {
t.Errorf("Expected 'Test: Reason 1', got '%s'", result)
}
}
func TestJoinReasons_Multiple(t *testing.T) {
result := joinReasons([]string{"Reason 1", "Reason 2", "Reason 3"}, "Test: ")
expected := "Test: Reason 1; Reason 2; Reason 3"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}

View File

@@ -0,0 +1,286 @@
package ucca
// ============================================================================
// Didactic Examples - Real-world scenarios for learning
// ============================================================================
// Example represents a didactic example case
type Example struct {
ID string `json:"id"`
Title string `json:"title"`
TitleDE string `json:"title_de"`
Description string `json:"description"`
DescriptionDE string `json:"description_de"`
Domain Domain `json:"domain"`
Outcome Feasibility `json:"outcome"`
OutcomeDE string `json:"outcome_de"`
Lessons string `json:"lessons"`
LessonsDE string `json:"lessons_de"`
// Matching criteria
MatchCriteria MatchCriteria `json:"-"`
}
// MatchCriteria defines when an example matches an intake
type MatchCriteria struct {
Domains []Domain
HasPersonalData *bool
HasArticle9Data *bool
HasMinorData *bool
AutomationLevels []AutomationLevel
HasScoring *bool
HasLegalEffects *bool
ModelUsageRAG *bool
ModelUsageTraining *bool
}
// boolPtr helper for creating bool pointers
func boolPtr(b bool) *bool {
return &b
}
// ExampleLibrary contains all didactic examples
var ExampleLibrary = []Example{
// =========================================================================
// Example 1: Parkhaus (CONDITIONAL)
// =========================================================================
{
ID: "EX-PARKHAUS",
Title: "Parking Garage License Plate Recognition",
TitleDE: "Parkhaus mit Kennzeichenerkennung",
Description: "A parking garage operator wants to use AI-based license plate recognition for automated entry/exit and billing. Cameras capture license plates, AI recognizes them, and matches to customer database.",
DescriptionDE: "Ein Parkhaus-Betreiber möchte KI-basierte Kennzeichenerkennung für automatisierte Ein-/Ausfahrt und Abrechnung nutzen. Kameras erfassen Kennzeichen, KI erkennt sie und gleicht mit Kundendatenbank ab.",
Domain: DomainGeneral,
Outcome: FeasibilityCONDITIONAL,
OutcomeDE: "BEDINGT MÖGLICH - Mit Auflagen umsetzbar",
Lessons: "License plates are personal data (linkable to vehicle owners). Requires: legal basis (contract performance), retention limits, access controls, deletion on request capability.",
LessonsDE: "Kennzeichen sind personenbezogene Daten (verknüpfbar mit Haltern). Erfordert: Rechtsgrundlage (Vertragserfüllung), Aufbewahrungsfristen, Zugriffskontrollen, Löschbarkeit auf Anfrage.",
MatchCriteria: MatchCriteria{
Domains: []Domain{DomainGeneral, DomainPublic},
HasPersonalData: boolPtr(true),
},
},
// =========================================================================
// Example 2: CFO-Gehalt (NO)
// =========================================================================
{
ID: "EX-CFO-GEHALT",
Title: "Executive Salary Prediction AI",
TitleDE: "KI zur CFO-Gehaltsvorhersage",
Description: "A company wants to train an AI model on historical CFO salary data to predict appropriate compensation for new executives. The training data includes names, companies, exact salaries, and performance metrics.",
DescriptionDE: "Ein Unternehmen möchte ein KI-Modell mit historischen CFO-Gehaltsdaten trainieren, um angemessene Vergütung für neue Führungskräfte vorherzusagen. Die Trainingsdaten umfassen Namen, Unternehmen, exakte Gehälter und Leistungskennzahlen.",
Domain: DomainFinance,
Outcome: FeasibilityNO,
OutcomeDE: "NICHT ZULÄSSIG - Erhebliche DSGVO-Verstöße",
Lessons: "Training with identifiable salary data of individuals violates purpose limitation, data minimization, and likely lacks legal basis. Alternative: Use aggregated, anonymized market studies.",
LessonsDE: "Training mit identifizierbaren Gehaltsdaten einzelner Personen verstößt gegen Zweckbindung, Datenminimierung und hat wahrscheinlich keine Rechtsgrundlage. Alternative: Aggregierte, anonymisierte Marktstudien nutzen.",
MatchCriteria: MatchCriteria{
Domains: []Domain{DomainFinance, DomainHR},
HasPersonalData: boolPtr(true),
ModelUsageTraining: boolPtr(true),
},
},
// =========================================================================
// Example 3: Stadtwerke-Chatbot (YES)
// =========================================================================
{
ID: "EX-STADTWERKE",
Title: "Utility Company Customer Service Chatbot",
TitleDE: "Stadtwerke-Chatbot für Kundenservice",
Description: "A municipal utility company wants to deploy an AI chatbot to answer customer questions about tariffs, bills, and services. The chatbot uses RAG with FAQ documents and does not store personal customer data.",
DescriptionDE: "Ein Stadtwerk möchte einen KI-Chatbot einsetzen, um Kundenfragen zu Tarifen, Rechnungen und Services zu beantworten. Der Chatbot nutzt RAG mit FAQ-Dokumenten und speichert keine personenbezogenen Kundendaten.",
Domain: DomainUtilities,
Outcome: FeasibilityYES,
OutcomeDE: "ZULÄSSIG - Niedriges Risiko",
Lessons: "RAG-only with public FAQ documents, no personal data storage, customer support purpose = low risk. Best practice: log only anonymized metrics, offer human escalation.",
LessonsDE: "Nur-RAG mit öffentlichen FAQ-Dokumenten, keine Speicherung personenbezogener Daten, Kundenservice-Zweck = geringes Risiko. Best Practice: Nur anonymisierte Metriken loggen, Eskalation zu Menschen anbieten.",
MatchCriteria: MatchCriteria{
Domains: []Domain{DomainUtilities, DomainPublic, DomainGeneral},
HasPersonalData: boolPtr(false),
ModelUsageRAG: boolPtr(true),
},
},
// =========================================================================
// Example 4: Schülerdaten (NO)
// =========================================================================
{
ID: "EX-SCHUELER",
Title: "Student Performance Scoring AI",
TitleDE: "KI-Bewertung von Schülerleistungen",
Description: "A school wants to use AI to automatically score and rank students based on their homework, test results, and classroom behavior. The scores would influence grade recommendations.",
DescriptionDE: "Eine Schule möchte KI nutzen, um Schüler automatisch basierend auf Hausaufgaben, Testergebnissen und Unterrichtsverhalten zu bewerten und zu ranken. Die Scores würden Notenempfehlungen beeinflussen.",
Domain: DomainEducation,
Outcome: FeasibilityNO,
OutcomeDE: "NICHT ZULÄSSIG - Schutz Minderjähriger",
Lessons: "Automated profiling and scoring of minors is prohibited. Children require special protection. Grades must remain human decisions. Alternative: AI for teacher suggestions, never automatic scoring.",
LessonsDE: "Automatisiertes Profiling und Scoring von Minderjährigen ist verboten. Kinder erfordern besonderen Schutz. Noten müssen menschliche Entscheidungen bleiben. Alternative: KI für Lehrervorschläge, niemals automatisches Scoring.",
MatchCriteria: MatchCriteria{
Domains: []Domain{DomainEducation},
HasMinorData: boolPtr(true),
HasScoring: boolPtr(true),
},
},
// =========================================================================
// Example 5: HR-Bewertung (NO)
// =========================================================================
{
ID: "EX-HR-BEWERTUNG",
Title: "Automated Employee Performance Evaluation",
TitleDE: "Automatisierte Mitarbeiterbewertung",
Description: "An HR department wants to fully automate quarterly performance reviews using AI. The system would analyze emails, meeting participation, and task completion to generate scores that directly determine bonuses and promotions.",
DescriptionDE: "Eine Personalabteilung möchte Quartalsbewertungen mit KI vollständig automatisieren. Das System würde E-Mails, Meeting-Teilnahme und Aufgabenerfüllung analysieren, um Scores zu generieren, die direkt Boni und Beförderungen bestimmen.",
Domain: DomainHR,
Outcome: FeasibilityNO,
OutcomeDE: "NICHT ZULÄSSIG - Art. 22 DSGVO Verstoß",
Lessons: "Fully automated decisions with legal/significant effects on employees violate Art. 22 GDPR. Requires human review. Also: email surveillance raises additional issues. Alternative: AI-assisted (not automated) evaluations with mandatory human decision.",
LessonsDE: "Vollautomatisierte Entscheidungen mit rechtlichen/erheblichen Auswirkungen auf Mitarbeiter verstoßen gegen Art. 22 DSGVO. Erfordert menschliche Überprüfung. Außerdem: E-Mail-Überwachung wirft zusätzliche Fragen auf. Alternative: KI-unterstützte (nicht automatisierte) Bewertungen mit verpflichtender menschlicher Entscheidung.",
MatchCriteria: MatchCriteria{
Domains: []Domain{DomainHR},
HasPersonalData: boolPtr(true),
HasScoring: boolPtr(true),
HasLegalEffects: boolPtr(true),
AutomationLevels: []AutomationLevel{AutomationFullyAutomated},
},
},
}
// MatchExamples calculates relevance of examples to the given intake
func MatchExamples(intake *UseCaseIntake) []ExampleMatch {
var matches []ExampleMatch
for _, ex := range ExampleLibrary {
similarity := calculateSimilarity(intake, ex.MatchCriteria)
if similarity > 0.3 { // Threshold for relevance
matches = append(matches, ExampleMatch{
ExampleID: ex.ID,
Title: ex.TitleDE,
Description: ex.DescriptionDE,
Similarity: similarity,
Outcome: ex.OutcomeDE,
Lessons: ex.LessonsDE,
})
}
}
// Sort by similarity (highest first)
for i := 0; i < len(matches)-1; i++ {
for j := i + 1; j < len(matches); j++ {
if matches[j].Similarity > matches[i].Similarity {
matches[i], matches[j] = matches[j], matches[i]
}
}
}
// Return top 3
if len(matches) > 3 {
matches = matches[:3]
}
return matches
}
// calculateSimilarity calculates how similar an intake is to example criteria
func calculateSimilarity(intake *UseCaseIntake, criteria MatchCriteria) float64 {
var score float64
var maxScore float64
// Domain match (weight: 3)
if len(criteria.Domains) > 0 {
maxScore += 3
for _, d := range criteria.Domains {
if intake.Domain == d {
score += 3
break
}
}
}
// Personal data match (weight: 2)
if criteria.HasPersonalData != nil {
maxScore += 2
if *criteria.HasPersonalData == intake.DataTypes.PersonalData {
score += 2
}
}
// Article 9 data match (weight: 2)
if criteria.HasArticle9Data != nil {
maxScore += 2
if *criteria.HasArticle9Data == intake.DataTypes.Article9Data {
score += 2
}
}
// Minor data match (weight: 3)
if criteria.HasMinorData != nil {
maxScore += 3
if *criteria.HasMinorData == intake.DataTypes.MinorData {
score += 3
}
}
// Automation level match (weight: 2)
if len(criteria.AutomationLevels) > 0 {
maxScore += 2
for _, a := range criteria.AutomationLevels {
if intake.Automation == a {
score += 2
break
}
}
}
// Scoring match (weight: 2)
if criteria.HasScoring != nil {
maxScore += 2
hasScoring := intake.Purpose.EvaluationScoring || intake.Outputs.RankingsOrScores
if *criteria.HasScoring == hasScoring {
score += 2
}
}
// Legal effects match (weight: 2)
if criteria.HasLegalEffects != nil {
maxScore += 2
if *criteria.HasLegalEffects == intake.Outputs.LegalEffects {
score += 2
}
}
// RAG usage match (weight: 1)
if criteria.ModelUsageRAG != nil {
maxScore += 1
if *criteria.ModelUsageRAG == intake.ModelUsage.RAG {
score += 1
}
}
// Training usage match (weight: 2)
if criteria.ModelUsageTraining != nil {
maxScore += 2
if *criteria.ModelUsageTraining == intake.ModelUsage.Training {
score += 2
}
}
if maxScore == 0 {
return 0
}
return score / maxScore
}
// GetExampleByID returns an example by its ID
func GetExampleByID(id string) *Example {
for _, ex := range ExampleLibrary {
if ex.ID == id {
return &ex
}
}
return nil
}
// GetAllExamples returns all available examples
func GetAllExamples() []Example {
return ExampleLibrary
}

View File

@@ -0,0 +1,734 @@
package ucca
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// ============================================================================
// Financial Regulations Policy Engine
// ============================================================================
//
// This engine evaluates financial use-cases against DORA, MaRisk, and BAIT rules.
// It extends the base PolicyEngine with financial-specific logic.
//
// Key regulations:
// - DORA (Digital Operational Resilience Act) - EU 2022/2554
// - MaRisk (Mindestanforderungen an das Risikomanagement) - BaFin
// - BAIT (Bankaufsichtliche Anforderungen an die IT) - BaFin
//
// ============================================================================
// DefaultFinancialPolicyPath is the default location for the financial policy file
var DefaultFinancialPolicyPath = "policies/financial_regulations_policy.yaml"
// FinancialPolicyConfig represents the financial regulations policy structure
type FinancialPolicyConfig struct {
Metadata FinancialPolicyMetadata `yaml:"metadata"`
ApplicableDomains []string `yaml:"applicable_domains"`
FactsSchema map[string]interface{} `yaml:"facts_schema"`
Controls map[string]FinancialControlDef `yaml:"controls"`
Gaps map[string]FinancialGapDef `yaml:"gaps"`
StopLines map[string]FinancialStopLine `yaml:"stop_lines"`
Rules []FinancialRuleDef `yaml:"rules"`
EscalationTriggers []FinancialEscalationTrigger `yaml:"escalation_triggers"`
}
// FinancialPolicyMetadata contains policy header information
type FinancialPolicyMetadata struct {
Version string `yaml:"version"`
EffectiveDate string `yaml:"effective_date"`
Author string `yaml:"author"`
Jurisdiction string `yaml:"jurisdiction"`
Regulations []FinancialRegulationInfo `yaml:"regulations"`
}
// FinancialRegulationInfo describes a regulation
type FinancialRegulationInfo struct {
Name string `yaml:"name"`
FullName string `yaml:"full_name"`
Reference string `yaml:"reference,omitempty"`
Authority string `yaml:"authority,omitempty"`
Version string `yaml:"version,omitempty"`
Effective string `yaml:"effective,omitempty"`
}
// FinancialControlDef represents a control specific to financial regulations
type FinancialControlDef struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Category string `yaml:"category"`
DORARef string `yaml:"dora_ref,omitempty"`
MaRiskRef string `yaml:"marisk_ref,omitempty"`
BAITRef string `yaml:"bait_ref,omitempty"`
MiFIDRef string `yaml:"mifid_ref,omitempty"`
GwGRef string `yaml:"gwg_ref,omitempty"`
Description string `yaml:"description"`
WhenApplicable []string `yaml:"when_applicable,omitempty"`
WhatToDo string `yaml:"what_to_do"`
EvidenceNeeded []string `yaml:"evidence_needed,omitempty"`
Effort string `yaml:"effort"`
}
// FinancialGapDef represents a compliance gap
type FinancialGapDef struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Severity string `yaml:"severity"`
Escalation string `yaml:"escalation,omitempty"`
When []string `yaml:"when,omitempty"`
Controls []string `yaml:"controls,omitempty"`
LegalRefs []string `yaml:"legal_refs,omitempty"`
}
// FinancialStopLine represents a hard blocker
type FinancialStopLine struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Outcome string `yaml:"outcome"`
When []string `yaml:"when,omitempty"`
Message string `yaml:"message"`
}
// FinancialRuleDef represents a rule from the financial policy
type FinancialRuleDef struct {
ID string `yaml:"id"`
Category string `yaml:"category"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Condition FinancialConditionDef `yaml:"condition"`
Effect FinancialEffectDef `yaml:"effect"`
Severity string `yaml:"severity"`
DORARef string `yaml:"dora_ref,omitempty"`
MaRiskRef string `yaml:"marisk_ref,omitempty"`
BAITRef string `yaml:"bait_ref,omitempty"`
MiFIDRef string `yaml:"mifid_ref,omitempty"`
GwGRef string `yaml:"gwg_ref,omitempty"`
Rationale string `yaml:"rationale"`
}
// FinancialConditionDef represents a rule condition
type FinancialConditionDef struct {
Field string `yaml:"field,omitempty"`
Operator string `yaml:"operator,omitempty"`
Value interface{} `yaml:"value,omitempty"`
AllOf []FinancialConditionDef `yaml:"all_of,omitempty"`
AnyOf []FinancialConditionDef `yaml:"any_of,omitempty"`
}
// FinancialEffectDef represents the effect when a rule triggers
type FinancialEffectDef struct {
Feasibility string `yaml:"feasibility,omitempty"`
ControlsAdd []string `yaml:"controls_add,omitempty"`
RiskAdd int `yaml:"risk_add,omitempty"`
Escalation bool `yaml:"escalation,omitempty"`
}
// FinancialEscalationTrigger defines when to escalate
type FinancialEscalationTrigger struct {
ID string `yaml:"id"`
Trigger []string `yaml:"trigger,omitempty"`
Level string `yaml:"level"`
Reason string `yaml:"reason"`
}
// ============================================================================
// Financial Policy Engine Implementation
// ============================================================================
// FinancialPolicyEngine evaluates intakes against financial regulations
type FinancialPolicyEngine struct {
config *FinancialPolicyConfig
}
// NewFinancialPolicyEngine creates a new financial policy engine
func NewFinancialPolicyEngine() (*FinancialPolicyEngine, error) {
searchPaths := []string{
DefaultFinancialPolicyPath,
filepath.Join(".", "policies", "financial_regulations_policy.yaml"),
filepath.Join("..", "policies", "financial_regulations_policy.yaml"),
filepath.Join("..", "..", "policies", "financial_regulations_policy.yaml"),
"/app/policies/financial_regulations_policy.yaml",
}
var data []byte
var err error
for _, path := range searchPaths {
data, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("failed to load financial policy from any known location: %w", err)
}
var config FinancialPolicyConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse financial policy YAML: %w", err)
}
return &FinancialPolicyEngine{config: &config}, nil
}
// NewFinancialPolicyEngineFromPath loads policy from a specific file path
func NewFinancialPolicyEngineFromPath(path string) (*FinancialPolicyEngine, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read financial policy file: %w", err)
}
var config FinancialPolicyConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse financial policy YAML: %w", err)
}
return &FinancialPolicyEngine{config: &config}, nil
}
// GetPolicyVersion returns the financial policy version
func (e *FinancialPolicyEngine) GetPolicyVersion() string {
return e.config.Metadata.Version
}
// IsApplicable checks if the financial policy applies to the given intake
func (e *FinancialPolicyEngine) IsApplicable(intake *UseCaseIntake) bool {
// Check if domain is in applicable domains
domain := strings.ToLower(string(intake.Domain))
for _, d := range e.config.ApplicableDomains {
if domain == d {
return true
}
}
return false
}
// Evaluate runs financial regulation rules against the intake
func (e *FinancialPolicyEngine) Evaluate(intake *UseCaseIntake) *FinancialAssessmentResult {
result := &FinancialAssessmentResult{
IsApplicable: e.IsApplicable(intake),
Feasibility: FeasibilityYES,
RiskScore: 0,
TriggeredRules: []FinancialTriggeredRule{},
RequiredControls: []FinancialRequiredControl{},
IdentifiedGaps: []FinancialIdentifiedGap{},
StopLinesHit: []FinancialStopLineHit{},
EscalationLevel: "",
PolicyVersion: e.config.Metadata.Version,
}
// If not applicable, return early
if !result.IsApplicable {
return result
}
// Check if financial context is provided
if intake.FinancialContext == nil {
result.MissingContext = true
return result
}
hasBlock := false
controlSet := make(map[string]bool)
needsEscalation := ""
// Evaluate each rule
for _, rule := range e.config.Rules {
if e.evaluateCondition(&rule.Condition, intake) {
triggered := FinancialTriggeredRule{
Code: rule.ID,
Category: rule.Category,
Title: rule.Title,
Description: rule.Description,
Severity: parseSeverity(rule.Severity),
ScoreDelta: rule.Effect.RiskAdd,
Rationale: rule.Rationale,
}
// Add regulation references
if rule.DORARef != "" {
triggered.DORARef = rule.DORARef
}
if rule.MaRiskRef != "" {
triggered.MaRiskRef = rule.MaRiskRef
}
if rule.BAITRef != "" {
triggered.BAITRef = rule.BAITRef
}
if rule.MiFIDRef != "" {
triggered.MiFIDRef = rule.MiFIDRef
}
result.TriggeredRules = append(result.TriggeredRules, triggered)
result.RiskScore += rule.Effect.RiskAdd
// Track severity
if parseSeverity(rule.Severity) == SeverityBLOCK {
hasBlock = true
}
// Override feasibility if specified
if rule.Effect.Feasibility != "" {
switch rule.Effect.Feasibility {
case "NO":
result.Feasibility = FeasibilityNO
case "CONDITIONAL":
if result.Feasibility != FeasibilityNO {
result.Feasibility = FeasibilityCONDITIONAL
}
}
}
// Collect controls
for _, ctrlID := range rule.Effect.ControlsAdd {
if !controlSet[ctrlID] {
controlSet[ctrlID] = true
if ctrl, ok := e.config.Controls[ctrlID]; ok {
result.RequiredControls = append(result.RequiredControls, FinancialRequiredControl{
ID: ctrl.ID,
Title: ctrl.Title,
Category: ctrl.Category,
Description: ctrl.Description,
WhatToDo: ctrl.WhatToDo,
EvidenceNeeded: ctrl.EvidenceNeeded,
Effort: ctrl.Effort,
DORARef: ctrl.DORARef,
MaRiskRef: ctrl.MaRiskRef,
BAITRef: ctrl.BAITRef,
})
}
}
}
// Track escalation
if rule.Effect.Escalation {
needsEscalation = e.determineEscalationLevel(intake)
}
}
}
// Check stop lines
for _, stopLine := range e.config.StopLines {
if e.evaluateStopLineConditions(stopLine.When, intake) {
result.StopLinesHit = append(result.StopLinesHit, FinancialStopLineHit{
ID: stopLine.ID,
Title: stopLine.Title,
Message: stopLine.Message,
Outcome: stopLine.Outcome,
})
result.Feasibility = FeasibilityNO
hasBlock = true
}
}
// Check gaps
for _, gap := range e.config.Gaps {
if e.evaluateGapConditions(gap.When, intake) {
result.IdentifiedGaps = append(result.IdentifiedGaps, FinancialIdentifiedGap{
ID: gap.ID,
Title: gap.Title,
Description: gap.Description,
Severity: parseSeverity(gap.Severity),
Controls: gap.Controls,
LegalRefs: gap.LegalRefs,
})
if gap.Escalation != "" && needsEscalation == "" {
needsEscalation = gap.Escalation
}
}
}
// Set final feasibility
if hasBlock {
result.Feasibility = FeasibilityNO
}
// Set escalation level
result.EscalationLevel = needsEscalation
// Generate summary
result.Summary = e.generateSummary(result)
return result
}
// evaluateCondition evaluates a condition against the intake
func (e *FinancialPolicyEngine) evaluateCondition(cond *FinancialConditionDef, intake *UseCaseIntake) bool {
// Handle composite all_of
if len(cond.AllOf) > 0 {
for _, subCond := range cond.AllOf {
if !e.evaluateCondition(&subCond, intake) {
return false
}
}
return true
}
// Handle composite any_of
if len(cond.AnyOf) > 0 {
for _, subCond := range cond.AnyOf {
if e.evaluateCondition(&subCond, intake) {
return true
}
}
return false
}
// Handle simple field condition
if cond.Field != "" {
return e.evaluateFieldCondition(cond.Field, cond.Operator, cond.Value, intake)
}
return false
}
// evaluateFieldCondition evaluates a single field comparison
func (e *FinancialPolicyEngine) evaluateFieldCondition(field, operator string, value interface{}, intake *UseCaseIntake) bool {
fieldValue := e.getFieldValue(field, intake)
if fieldValue == nil {
return false
}
switch operator {
case "equals":
return e.compareEquals(fieldValue, value)
case "not_equals":
return !e.compareEquals(fieldValue, value)
case "in":
return e.compareIn(fieldValue, value)
default:
return false
}
}
// getFieldValue extracts a field value from the intake
func (e *FinancialPolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interface{} {
parts := strings.Split(field, ".")
if len(parts) == 0 {
return nil
}
switch parts[0] {
case "domain":
return strings.ToLower(string(intake.Domain))
case "financial_entity":
if len(parts) < 2 || intake.FinancialContext == nil {
return nil
}
return e.getFinancialEntityValue(parts[1], intake.FinancialContext)
case "ict_service":
if len(parts) < 2 || intake.FinancialContext == nil {
return nil
}
return e.getICTServiceValue(parts[1], intake.FinancialContext)
case "ai_application":
if len(parts) < 2 || intake.FinancialContext == nil {
return nil
}
return e.getAIApplicationValue(parts[1], intake.FinancialContext)
case "model_usage":
if len(parts) < 2 {
return nil
}
switch parts[1] {
case "training":
return intake.ModelUsage.Training
case "finetune":
return intake.ModelUsage.Finetune
case "rag":
return intake.ModelUsage.RAG
}
}
return nil
}
func (e *FinancialPolicyEngine) getFinancialEntityValue(field string, ctx *FinancialContext) interface{} {
switch field {
case "type":
return string(ctx.FinancialEntity.Type)
case "regulated":
return ctx.FinancialEntity.Regulated
case "size_category":
return string(ctx.FinancialEntity.SizeCategory)
}
return nil
}
func (e *FinancialPolicyEngine) getICTServiceValue(field string, ctx *FinancialContext) interface{} {
switch field {
case "is_critical":
return ctx.ICTService.IsCritical
case "is_outsourced":
return ctx.ICTService.IsOutsourced
case "provider_location":
return string(ctx.ICTService.ProviderLocation)
case "concentration_risk":
return ctx.ICTService.ConcentrationRisk
}
return nil
}
func (e *FinancialPolicyEngine) getAIApplicationValue(field string, ctx *FinancialContext) interface{} {
switch field {
case "affects_customer_decisions":
return ctx.AIApplication.AffectsCustomerDecisions
case "algorithmic_trading":
return ctx.AIApplication.AlgorithmicTrading
case "risk_assessment":
return ctx.AIApplication.RiskAssessment
case "aml_kyc":
return ctx.AIApplication.AMLKYC
case "model_validation_done":
return ctx.AIApplication.ModelValidationDone
}
return nil
}
// compareEquals compares two values for equality
func (e *FinancialPolicyEngine) compareEquals(fieldValue, expected interface{}) bool {
if bv, ok := fieldValue.(bool); ok {
if eb, ok := expected.(bool); ok {
return bv == eb
}
}
if sv, ok := fieldValue.(string); ok {
if es, ok := expected.(string); ok {
return strings.EqualFold(sv, es)
}
}
return false
}
// compareIn checks if fieldValue is in a list
func (e *FinancialPolicyEngine) compareIn(fieldValue, expected interface{}) bool {
list, ok := expected.([]interface{})
if !ok {
return false
}
sv, ok := fieldValue.(string)
if !ok {
return false
}
for _, item := range list {
if is, ok := item.(string); ok && strings.EqualFold(is, sv) {
return true
}
}
return false
}
// evaluateStopLineConditions evaluates stop line conditions
func (e *FinancialPolicyEngine) evaluateStopLineConditions(conditions []string, intake *UseCaseIntake) bool {
if intake.FinancialContext == nil {
return false
}
for _, cond := range conditions {
if !e.parseAndEvaluateSimpleCondition(cond, intake) {
return false
}
}
return len(conditions) > 0
}
// evaluateGapConditions evaluates gap conditions
func (e *FinancialPolicyEngine) evaluateGapConditions(conditions []string, intake *UseCaseIntake) bool {
if intake.FinancialContext == nil {
return false
}
for _, cond := range conditions {
if !e.parseAndEvaluateSimpleCondition(cond, intake) {
return false
}
}
return len(conditions) > 0
}
// parseAndEvaluateSimpleCondition parses "field == value" style conditions
func (e *FinancialPolicyEngine) parseAndEvaluateSimpleCondition(condition string, intake *UseCaseIntake) bool {
// Parse "field == value" or "field != value"
if strings.Contains(condition, "==") {
parts := strings.SplitN(condition, "==", 2)
if len(parts) != 2 {
return false
}
field := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
fieldVal := e.getFieldValue(field, intake)
if fieldVal == nil {
return false
}
// Handle boolean values
if value == "true" {
if bv, ok := fieldVal.(bool); ok {
return bv
}
} else if value == "false" {
if bv, ok := fieldVal.(bool); ok {
return !bv
}
}
// Handle string values
if sv, ok := fieldVal.(string); ok {
return strings.EqualFold(sv, value)
}
}
return false
}
// determineEscalationLevel determines the appropriate escalation level
func (e *FinancialPolicyEngine) determineEscalationLevel(intake *UseCaseIntake) string {
if intake.FinancialContext == nil {
return ""
}
ctx := intake.FinancialContext
// E3: Highest level for critical cases
if ctx.AIApplication.AlgorithmicTrading {
return "E3"
}
if ctx.ICTService.IsCritical && ctx.ICTService.IsOutsourced {
return "E3"
}
// E2: Medium level
if ctx.AIApplication.RiskAssessment || ctx.AIApplication.AffectsCustomerDecisions {
return "E2"
}
return "E1"
}
// generateSummary creates a human-readable summary
func (e *FinancialPolicyEngine) generateSummary(result *FinancialAssessmentResult) string {
var parts []string
switch result.Feasibility {
case FeasibilityYES:
parts = append(parts, "Der Use Case ist aus regulatorischer Sicht (DORA/MaRisk/BAIT) grundsätzlich umsetzbar.")
case FeasibilityCONDITIONAL:
parts = append(parts, "Der Use Case ist unter Einhaltung der Finanzregulierungen bedingt umsetzbar.")
case FeasibilityNO:
parts = append(parts, "Der Use Case ist ohne weitere Maßnahmen regulatorisch nicht zulässig.")
}
if len(result.StopLinesHit) > 0 {
parts = append(parts, fmt.Sprintf("%d kritische Stop-Lines wurden ausgelöst.", len(result.StopLinesHit)))
}
if len(result.IdentifiedGaps) > 0 {
parts = append(parts, fmt.Sprintf("%d Compliance-Lücken wurden identifiziert.", len(result.IdentifiedGaps)))
}
if len(result.RequiredControls) > 0 {
parts = append(parts, fmt.Sprintf("%d regulatorische Kontrollen sind erforderlich.", len(result.RequiredControls)))
}
if result.EscalationLevel != "" {
parts = append(parts, fmt.Sprintf("Eskalation auf Stufe %s empfohlen.", result.EscalationLevel))
}
return strings.Join(parts, " ")
}
// GetAllControls returns all controls in the financial policy
func (e *FinancialPolicyEngine) GetAllControls() map[string]FinancialControlDef {
return e.config.Controls
}
// GetAllGaps returns all gaps in the financial policy
func (e *FinancialPolicyEngine) GetAllGaps() map[string]FinancialGapDef {
return e.config.Gaps
}
// GetAllStopLines returns all stop lines in the financial policy
func (e *FinancialPolicyEngine) GetAllStopLines() map[string]FinancialStopLine {
return e.config.StopLines
}
// GetApplicableDomains returns domains where financial regulations apply
func (e *FinancialPolicyEngine) GetApplicableDomains() []string {
return e.config.ApplicableDomains
}
// ============================================================================
// Financial Assessment Result Types
// ============================================================================
// FinancialAssessmentResult represents the result of financial regulation evaluation
type FinancialAssessmentResult struct {
IsApplicable bool `json:"is_applicable"`
MissingContext bool `json:"missing_context,omitempty"`
Feasibility Feasibility `json:"feasibility"`
RiskScore int `json:"risk_score"`
TriggeredRules []FinancialTriggeredRule `json:"triggered_rules"`
RequiredControls []FinancialRequiredControl `json:"required_controls"`
IdentifiedGaps []FinancialIdentifiedGap `json:"identified_gaps"`
StopLinesHit []FinancialStopLineHit `json:"stop_lines_hit"`
EscalationLevel string `json:"escalation_level,omitempty"`
Summary string `json:"summary"`
PolicyVersion string `json:"policy_version"`
}
// FinancialTriggeredRule represents a triggered financial regulation rule
type FinancialTriggeredRule struct {
Code string `json:"code"`
Category string `json:"category"`
Title string `json:"title"`
Description string `json:"description"`
Severity Severity `json:"severity"`
ScoreDelta int `json:"score_delta"`
DORARef string `json:"dora_ref,omitempty"`
MaRiskRef string `json:"marisk_ref,omitempty"`
BAITRef string `json:"bait_ref,omitempty"`
MiFIDRef string `json:"mifid_ref,omitempty"`
Rationale string `json:"rationale"`
}
// FinancialRequiredControl represents a required control
type FinancialRequiredControl struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Description string `json:"description"`
WhatToDo string `json:"what_to_do"`
EvidenceNeeded []string `json:"evidence_needed,omitempty"`
Effort string `json:"effort"`
DORARef string `json:"dora_ref,omitempty"`
MaRiskRef string `json:"marisk_ref,omitempty"`
BAITRef string `json:"bait_ref,omitempty"`
}
// FinancialIdentifiedGap represents an identified compliance gap
type FinancialIdentifiedGap struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Severity Severity `json:"severity"`
Controls []string `json:"controls,omitempty"`
LegalRefs []string `json:"legal_refs,omitempty"`
}
// FinancialStopLineHit represents a hit stop line
type FinancialStopLineHit struct {
ID string `json:"id"`
Title string `json:"title"`
Message string `json:"message"`
Outcome string `json:"outcome"`
}

View File

@@ -0,0 +1,618 @@
package ucca
import (
"testing"
)
// ============================================================================
// Financial Policy Engine Tests
// ============================================================================
func TestFinancialPolicyEngine_NewEngine(t *testing.T) {
// Try to load the financial policy engine
engine, err := NewFinancialPolicyEngineFromPath("../../policies/financial_regulations_policy.yaml")
if err != nil {
t.Skipf("Skipping test - policy file not found: %v", err)
}
if engine == nil {
t.Fatal("Engine should not be nil")
}
version := engine.GetPolicyVersion()
if version == "" {
t.Error("Policy version should not be empty")
}
}
func TestFinancialPolicyEngine_IsApplicable(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
tests := []struct {
name string
domain Domain
expected bool
}{
{"Banking domain is applicable", DomainBanking, true},
{"Finance domain is applicable", DomainFinance, true},
{"Insurance domain is applicable", DomainInsurance, true},
{"Investment domain is applicable", DomainInvestment, true},
{"Healthcare is not applicable", DomainHealthcare, false},
{"Retail is not applicable", DomainRetail, false},
{"Education is not applicable", DomainEducation, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
intake := &UseCaseIntake{
Domain: tt.domain,
}
result := engine.IsApplicable(intake)
if result != tt.expected {
t.Errorf("IsApplicable() = %v, want %v", result, tt.expected)
}
})
}
}
func TestFinancialPolicyEngine_Evaluate_NonApplicableDomain(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainHealthcare,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
},
}
result := engine.Evaluate(intake)
if result.IsApplicable {
t.Error("Should not be applicable for healthcare domain")
}
if len(result.TriggeredRules) > 0 {
t.Error("No rules should trigger for non-applicable domain")
}
}
func TestFinancialPolicyEngine_Evaluate_MissingContext(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: nil, // No financial context provided
}
result := engine.Evaluate(intake)
if !result.IsApplicable {
t.Error("Should be applicable for banking domain")
}
if !result.MissingContext {
t.Error("Should indicate missing financial context")
}
}
func TestFinancialPolicyEngine_Evaluate_RegulatedBank(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
SizeCategory: SizeCategoryLessSignificant,
},
ICTService: ICTService{
IsCritical: false,
IsOutsourced: false,
ProviderLocation: ProviderLocationEU,
},
AIApplication: FinancialAIApplication{
AffectsCustomerDecisions: false,
RiskAssessment: false,
},
},
}
result := engine.Evaluate(intake)
if !result.IsApplicable {
t.Error("Should be applicable for banking domain")
}
if result.MissingContext {
t.Error("Context should not be missing")
}
// Should trigger basic DORA/BAIT rules for regulated banks
if len(result.TriggeredRules) == 0 {
t.Error("Should trigger at least basic regulatory rules")
}
// Check for DORA control requirements
hasDORAControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.Category == "DORA" || ctrl.Category == "BAIT" {
hasDORAControl = true
break
}
}
if !hasDORAControl {
t.Error("Should require DORA or BAIT controls for regulated bank")
}
}
func TestFinancialPolicyEngine_Evaluate_CriticalICTOutsourcing(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
ICTService: ICTService{
IsCritical: true,
IsOutsourced: true,
ProviderLocation: ProviderLocationEU,
},
},
}
result := engine.Evaluate(intake)
// Should have elevated risk for critical ICT outsourcing
if result.RiskScore == 0 {
t.Error("Risk score should be elevated for critical ICT outsourcing")
}
// Should require TPP risk management control
hasTPPControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "CTRL-DORA-TPP-RISK-MANAGEMENT" || ctrl.ID == "CTRL-MARISK-OUTSOURCING" {
hasTPPControl = true
break
}
}
if !hasTPPControl {
t.Error("Should require TPP risk management for critical outsourcing")
}
// Should trigger escalation
if result.EscalationLevel == "" {
t.Error("Should trigger escalation for critical ICT outsourcing")
}
}
func TestFinancialPolicyEngine_Evaluate_UnvalidatedRiskModel(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
ICTService: ICTService{
IsCritical: false,
IsOutsourced: false,
},
AIApplication: FinancialAIApplication{
RiskAssessment: true,
ModelValidationDone: false, // Not validated!
},
},
}
result := engine.Evaluate(intake)
// Should block unvalidated risk models
if result.Feasibility != FeasibilityNO {
t.Error("Should block use case with unvalidated risk model")
}
// Should require model validation control
hasValidationControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "CTRL-MARISK-MODEL-VALIDATION" {
hasValidationControl = true
break
}
}
if !hasValidationControl {
t.Error("Should require MaRisk model validation control")
}
}
func TestFinancialPolicyEngine_Evaluate_ValidatedRiskModel(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
ICTService: ICTService{
IsCritical: false,
IsOutsourced: false,
},
AIApplication: FinancialAIApplication{
RiskAssessment: true,
ModelValidationDone: true, // Validated!
},
},
}
result := engine.Evaluate(intake)
// Should not block validated risk models
if result.Feasibility == FeasibilityNO {
t.Error("Should not block use case with validated risk model")
}
}
func TestFinancialPolicyEngine_Evaluate_AlgorithmicTrading(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainInvestment,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityInvestmentFirm,
Regulated: true,
},
AIApplication: FinancialAIApplication{
AlgorithmicTrading: true,
},
},
}
result := engine.Evaluate(intake)
// Should require algorithmic trading control
hasAlgoControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "CTRL-FIN-ALGO-TRADING" {
hasAlgoControl = true
break
}
}
if !hasAlgoControl {
t.Error("Should require algorithmic trading control")
}
// Should trigger highest escalation
if result.EscalationLevel != "E3" {
t.Errorf("Should trigger E3 escalation for algo trading, got %s", result.EscalationLevel)
}
}
func TestFinancialPolicyEngine_Evaluate_CustomerDecisions(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
AIApplication: FinancialAIApplication{
AffectsCustomerDecisions: true,
},
},
}
result := engine.Evaluate(intake)
// Should require explainability control
hasExplainControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "CTRL-FIN-AI-EXPLAINABILITY" {
hasExplainControl = true
break
}
}
if !hasExplainControl {
t.Error("Should require AI explainability control for customer decisions")
}
// Should trigger E2 escalation
if result.EscalationLevel != "E2" {
t.Errorf("Should trigger E2 escalation for customer decisions, got %s", result.EscalationLevel)
}
}
func TestFinancialPolicyEngine_Evaluate_AMLKYC(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
AIApplication: FinancialAIApplication{
AMLKYC: true,
},
},
}
result := engine.Evaluate(intake)
// Should require AML control
hasAMLControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "CTRL-FIN-AML-AI" {
hasAMLControl = true
break
}
}
if !hasAMLControl {
t.Error("Should require AML AI control for KYC/AML use cases")
}
}
func TestFinancialPolicyEngine_Evaluate_ThirdCountryCritical(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
ICTService: ICTService{
IsCritical: true,
IsOutsourced: true,
ProviderLocation: ProviderLocationThirdCountry,
},
},
}
result := engine.Evaluate(intake)
// Should be conditional at minimum
if result.Feasibility == FeasibilityYES {
t.Error("Should not allow critical ICT in third country without conditions")
}
// Should have elevated risk
if result.RiskScore < 30 {
t.Error("Should have elevated risk score for third country critical ICT")
}
}
func TestFinancialPolicyEngine_Evaluate_ConcentrationRisk(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
ICTService: ICTService{
IsOutsourced: true,
ConcentrationRisk: true,
},
},
}
result := engine.Evaluate(intake)
// Should trigger escalation for concentration risk
if result.EscalationLevel == "" {
t.Error("Should trigger escalation for concentration risk")
}
// Should add risk
if result.RiskScore == 0 {
t.Error("Should add risk for concentration risk")
}
}
func TestFinancialPolicyEngine_Evaluate_InsuranceCompany(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainInsurance,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityInsuranceCompany,
Regulated: true,
},
},
}
result := engine.Evaluate(intake)
if !result.IsApplicable {
t.Error("Should be applicable for insurance domain")
}
}
func TestFinancialPolicyEngine_GetAllControls(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
controls := engine.GetAllControls()
if len(controls) == 0 {
t.Error("Should have controls defined")
}
// Check for key DORA controls
keyControls := []string{
"CTRL-DORA-ICT-RISK-FRAMEWORK",
"CTRL-DORA-ICT-INCIDENT-MANAGEMENT",
"CTRL-DORA-TPP-RISK-MANAGEMENT",
"CTRL-MARISK-MODEL-VALIDATION",
"CTRL-BAIT-SDLC",
}
for _, key := range keyControls {
if _, ok := controls[key]; !ok {
t.Errorf("Should have control %s", key)
}
}
}
func TestFinancialPolicyEngine_GetAllGaps(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
gaps := engine.GetAllGaps()
if len(gaps) == 0 {
t.Error("Should have gaps defined")
}
// Check for key gaps
keyGaps := []string{
"GAP_DORA_NOT_IMPLEMENTED",
"GAP_MARISK_MODEL_NOT_VALIDATED",
}
for _, key := range keyGaps {
if _, ok := gaps[key]; !ok {
t.Errorf("Should have gap %s", key)
}
}
}
func TestFinancialPolicyEngine_GetAllStopLines(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
stopLines := engine.GetAllStopLines()
if len(stopLines) == 0 {
t.Error("Should have stop lines defined")
}
// Check for key stop lines
keyStopLines := []string{
"STOP_MARISK_UNVALIDATED_RISK_MODEL",
"STOP_ALGO_TRADING_WITHOUT_APPROVAL",
}
for _, key := range keyStopLines {
if _, ok := stopLines[key]; !ok {
t.Errorf("Should have stop line %s", key)
}
}
}
func TestFinancialPolicyEngine_Determinism(t *testing.T) {
engine := createTestFinancialEngine(t)
if engine == nil {
return
}
intake := &UseCaseIntake{
Domain: DomainBanking,
FinancialContext: &FinancialContext{
FinancialEntity: FinancialEntity{
Type: FinancialEntityCreditInstitution,
Regulated: true,
},
ICTService: ICTService{
IsCritical: true,
IsOutsourced: true,
},
AIApplication: FinancialAIApplication{
AffectsCustomerDecisions: true,
RiskAssessment: true,
ModelValidationDone: true,
},
},
}
// Run evaluation multiple times
var lastResult *FinancialAssessmentResult
for i := 0; i < 10; i++ {
result := engine.Evaluate(intake)
if lastResult != nil {
if result.Feasibility != lastResult.Feasibility {
t.Error("Feasibility should be deterministic")
}
if result.RiskScore != lastResult.RiskScore {
t.Error("Risk score should be deterministic")
}
if len(result.TriggeredRules) != len(lastResult.TriggeredRules) {
t.Error("Triggered rules should be deterministic")
}
if len(result.RequiredControls) != len(lastResult.RequiredControls) {
t.Error("Required controls should be deterministic")
}
}
lastResult = result
}
}
// ============================================================================
// Helper Functions
// ============================================================================
func createTestFinancialEngine(t *testing.T) *FinancialPolicyEngine {
engine, err := NewFinancialPolicyEngineFromPath("../../policies/financial_regulations_policy.yaml")
if err != nil {
t.Skipf("Skipping test - policy file not found: %v", err)
return nil
}
return engine
}

View File

@@ -0,0 +1,394 @@
package ucca
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// LegalRAGClient provides access to the legal corpus vector search.
type LegalRAGClient struct {
qdrantHost string
qdrantPort string
embeddingURL string
collection string
httpClient *http.Client
}
// LegalSearchResult represents a single search result from the legal corpus.
type LegalSearchResult struct {
Text string `json:"text"`
RegulationCode string `json:"regulation_code"`
RegulationName string `json:"regulation_name"`
Article string `json:"article,omitempty"`
Paragraph string `json:"paragraph,omitempty"`
SourceURL string `json:"source_url"`
Score float64 `json:"score"`
}
// LegalContext represents aggregated legal context for an assessment.
type LegalContext struct {
Query string `json:"query"`
Results []LegalSearchResult `json:"results"`
RelevantArticles []string `json:"relevant_articles"`
Regulations []string `json:"regulations"`
GeneratedAt time.Time `json:"generated_at"`
}
// NewLegalRAGClient creates a new Legal RAG client.
func NewLegalRAGClient() *LegalRAGClient {
qdrantHost := os.Getenv("QDRANT_HOST")
if qdrantHost == "" {
qdrantHost = "localhost"
}
qdrantPort := os.Getenv("QDRANT_PORT")
if qdrantPort == "" {
qdrantPort = "6333"
}
embeddingURL := os.Getenv("EMBEDDING_SERVICE_URL")
if embeddingURL == "" {
embeddingURL = "http://localhost:8087"
}
return &LegalRAGClient{
qdrantHost: qdrantHost,
qdrantPort: qdrantPort,
embeddingURL: embeddingURL,
collection: "bp_legal_corpus",
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// embeddingResponse from the embedding service.
type embeddingResponse struct {
Embeddings [][]float64 `json:"embeddings"`
}
// qdrantSearchRequest for Qdrant REST API.
type qdrantSearchRequest struct {
Vector []float64 `json:"vector"`
Limit int `json:"limit"`
WithPayload bool `json:"with_payload"`
Filter *qdrantFilter `json:"filter,omitempty"`
}
type qdrantFilter struct {
Should []qdrantCondition `json:"should,omitempty"`
Must []qdrantCondition `json:"must,omitempty"`
}
type qdrantCondition struct {
Key string `json:"key"`
Match qdrantMatch `json:"match"`
}
type qdrantMatch struct {
Value string `json:"value"`
}
// qdrantSearchResponse from Qdrant REST API.
type qdrantSearchResponse struct {
Result []qdrantSearchHit `json:"result"`
}
type qdrantSearchHit struct {
ID string `json:"id"`
Score float64 `json:"score"`
Payload map[string]interface{} `json:"payload"`
}
// generateEmbedding calls the embedding service to get a vector for the query.
func (c *LegalRAGClient) generateEmbedding(ctx context.Context, text string) ([]float64, error) {
reqBody := map[string]interface{}{
"texts": []string{text},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal embedding request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.embeddingURL+"/embed", bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create embedding request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("embedding request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embedding service returned %d: %s", resp.StatusCode, string(body))
}
var embResp embeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&embResp); err != nil {
return nil, fmt.Errorf("failed to decode embedding response: %w", err)
}
if len(embResp.Embeddings) == 0 {
return nil, fmt.Errorf("no embeddings returned")
}
return embResp.Embeddings[0], nil
}
// Search queries the legal corpus for relevant passages.
func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationCodes []string, topK int) ([]LegalSearchResult, error) {
// Generate query embedding
embedding, err := c.generateEmbedding(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to generate embedding: %w", err)
}
// Build Qdrant search request
searchReq := qdrantSearchRequest{
Vector: embedding,
Limit: topK,
WithPayload: true,
}
// Add filter for specific regulations if provided
if len(regulationCodes) > 0 {
conditions := make([]qdrantCondition, len(regulationCodes))
for i, code := range regulationCodes {
conditions[i] = qdrantCondition{
Key: "regulation_code",
Match: qdrantMatch{Value: code},
}
}
searchReq.Filter = &qdrantFilter{Should: conditions}
}
jsonBody, err := json.Marshal(searchReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal search request: %w", err)
}
// Call Qdrant
url := fmt.Sprintf("http://%s:%s/collections/%s/points/search", c.qdrantHost, c.qdrantPort, c.collection)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create search request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("search request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("qdrant returned %d: %s", resp.StatusCode, string(body))
}
var searchResp qdrantSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode search response: %w", err)
}
// Convert to results
results := make([]LegalSearchResult, len(searchResp.Result))
for i, hit := range searchResp.Result {
results[i] = LegalSearchResult{
Text: getString(hit.Payload, "text"),
RegulationCode: getString(hit.Payload, "regulation_code"),
RegulationName: getString(hit.Payload, "regulation_name"),
Article: getString(hit.Payload, "article"),
Paragraph: getString(hit.Payload, "paragraph"),
SourceURL: getString(hit.Payload, "source_url"),
Score: hit.Score,
}
}
return results, nil
}
// GetLegalContextForAssessment retrieves relevant legal context for an assessment.
func (c *LegalRAGClient) GetLegalContextForAssessment(ctx context.Context, assessment *Assessment) (*LegalContext, error) {
// Build query from assessment data
queryParts := []string{}
// Add domain context
if assessment.Domain != "" {
queryParts = append(queryParts, fmt.Sprintf("KI-Anwendung im Bereich %s", assessment.Domain))
}
// Add data type context
if assessment.Intake.DataTypes.Article9Data {
queryParts = append(queryParts, "besondere Kategorien personenbezogener Daten Art. 9 DSGVO")
}
if assessment.Intake.DataTypes.PersonalData {
queryParts = append(queryParts, "personenbezogene Daten")
}
if assessment.Intake.DataTypes.MinorData {
queryParts = append(queryParts, "Daten von Minderjährigen")
}
// Add purpose context
if assessment.Intake.Purpose.EvaluationScoring {
queryParts = append(queryParts, "automatisierte Bewertung Scoring")
}
if assessment.Intake.Purpose.DecisionMaking {
queryParts = append(queryParts, "automatisierte Entscheidung Art. 22 DSGVO")
}
if assessment.Intake.Purpose.Profiling {
queryParts = append(queryParts, "Profiling")
}
// Add risk-specific context
if assessment.DSFARecommended {
queryParts = append(queryParts, "Datenschutz-Folgenabschätzung Art. 35 DSGVO")
}
if assessment.Art22Risk {
queryParts = append(queryParts, "automatisierte Einzelentscheidung rechtliche Wirkung")
}
// Build final query
query := strings.Join(queryParts, " ")
if query == "" {
query = "DSGVO Anforderungen KI-System Datenschutz"
}
// Determine which regulations to search based on triggered rules
regulationCodes := c.determineRelevantRegulations(assessment)
// Search legal corpus
results, err := c.Search(ctx, query, regulationCodes, 5)
if err != nil {
return nil, err
}
// Extract unique articles and regulations
articleSet := make(map[string]bool)
regSet := make(map[string]bool)
for _, r := range results {
if r.Article != "" {
key := fmt.Sprintf("%s Art. %s", r.RegulationCode, r.Article)
articleSet[key] = true
}
regSet[r.RegulationCode] = true
}
articles := make([]string, 0, len(articleSet))
for a := range articleSet {
articles = append(articles, a)
}
regulations := make([]string, 0, len(regSet))
for r := range regSet {
regulations = append(regulations, r)
}
return &LegalContext{
Query: query,
Results: results,
RelevantArticles: articles,
Regulations: regulations,
GeneratedAt: time.Now().UTC(),
}, nil
}
// determineRelevantRegulations determines which regulations to search based on the assessment.
func (c *LegalRAGClient) determineRelevantRegulations(assessment *Assessment) []string {
codes := []string{"GDPR"} // Always include GDPR
// Check triggered rules for regulation hints
for _, rule := range assessment.TriggeredRules {
gdprRef := rule.GDPRRef
if strings.Contains(gdprRef, "AI Act") || strings.Contains(gdprRef, "KI-VO") {
codes = append(codes, "AIACT")
}
if strings.Contains(gdprRef, "Art. 9") || strings.Contains(gdprRef, "Art. 22") {
// Already have GDPR
}
}
// Add AI Act if AI-related controls are required
for _, ctrl := range assessment.RequiredControls {
if strings.HasPrefix(ctrl.ID, "AI-") {
if !contains(codes, "AIACT") {
codes = append(codes, "AIACT")
}
break
}
}
// Add BSI if security controls are required
for _, ctrl := range assessment.RequiredControls {
if strings.HasPrefix(ctrl.ID, "CRYPTO-") || strings.HasPrefix(ctrl.ID, "IAM-") {
codes = append(codes, "BSI-TR-03161-1")
break
}
}
return codes
}
// FormatLegalContextForPrompt formats the legal context for inclusion in an LLM prompt.
func (c *LegalRAGClient) FormatLegalContextForPrompt(lc *LegalContext) string {
if lc == nil || len(lc.Results) == 0 {
return ""
}
var buf bytes.Buffer
buf.WriteString("\n\n**Relevante Rechtsgrundlagen:**\n\n")
for i, result := range lc.Results {
buf.WriteString(fmt.Sprintf("%d. **%s** (%s)", i+1, result.RegulationName, result.RegulationCode))
if result.Article != "" {
buf.WriteString(fmt.Sprintf(" - Art. %s", result.Article))
if result.Paragraph != "" {
buf.WriteString(fmt.Sprintf(" Abs. %s", result.Paragraph))
}
}
buf.WriteString("\n")
buf.WriteString(fmt.Sprintf(" > %s\n\n", truncateText(result.Text, 300)))
}
return buf.String()
}
// Helper functions
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
return text[:maxLen] + "..."
}

View File

@@ -0,0 +1,583 @@
package ucca
import (
"fmt"
"strings"
"time"
)
// =============================================================================
// License Policy Engine
// Handles license/copyright compliance for standards and norms
// =============================================================================
// LicensedContentFacts represents the license-related facts from the wizard
type LicensedContentFacts struct {
Present bool `json:"present"`
Publisher string `json:"publisher"` // DIN_MEDIA, VDI, VDE, ISO, etc.
LicenseType string `json:"license_type"` // SINGLE_WORKSTATION, NETWORK_INTRANET, etc.
AIUsePermitted string `json:"ai_use_permitted"` // YES, NO, UNKNOWN
ProofUploaded bool `json:"proof_uploaded"`
OperationMode string `json:"operation_mode"` // LINK_ONLY, NOTES_ONLY, FULLTEXT_RAG, TRAINING
DistributionScope string `json:"distribution_scope"` // SINGLE_USER, COMPANY_INTERNAL, etc.
ContentType string `json:"content_type"` // NORM_FULLTEXT, CUSTOMER_NOTES, etc.
}
// LicensePolicyResult represents the evaluation result
type LicensePolicyResult struct {
Allowed bool `json:"allowed"`
EffectiveMode string `json:"effective_mode"` // The mode that will actually be used
Reason string `json:"reason"`
Gaps []LicenseGap `json:"gaps"`
RequiredControls []LicenseControl `json:"required_controls"`
StopLine *LicenseStopLine `json:"stop_line,omitempty"` // If hard blocked
OutputRestrictions *OutputRestrictions `json:"output_restrictions"`
EscalationLevel string `json:"escalation_level"`
RiskScore int `json:"risk_score"`
}
// LicenseGap represents a license-related gap
type LicenseGap struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Controls []string `json:"controls"`
Severity string `json:"severity"`
}
// LicenseControl represents a required control for license compliance
type LicenseControl struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
WhatToDo string `json:"what_to_do"`
Evidence []string `json:"evidence_needed"`
}
// LicenseStopLine represents a hard block
type LicenseStopLine struct {
ID string `json:"id"`
Title string `json:"title"`
Message string `json:"message"`
Outcome string `json:"outcome"` // NOT_ALLOWED, NOT_ALLOWED_UNTIL_LICENSE_CLEARED
}
// OutputRestrictions defines how outputs should be filtered
type OutputRestrictions struct {
AllowQuotes bool `json:"allow_quotes"`
MaxQuoteLength int `json:"max_quote_length"` // in characters
RequireCitation bool `json:"require_citation"`
AllowCopy bool `json:"allow_copy"`
AllowExport bool `json:"allow_export"`
}
// LicensePolicyEngine evaluates license compliance
type LicensePolicyEngine struct {
// Configuration can be added here
}
// NewLicensePolicyEngine creates a new license policy engine
func NewLicensePolicyEngine() *LicensePolicyEngine {
return &LicensePolicyEngine{}
}
// Evaluate evaluates the license facts and returns the policy result
func (e *LicensePolicyEngine) Evaluate(facts *LicensedContentFacts) *LicensePolicyResult {
result := &LicensePolicyResult{
Allowed: true,
EffectiveMode: "LINK_ONLY", // Default safe mode
Gaps: []LicenseGap{},
RequiredControls: []LicenseControl{},
OutputRestrictions: &OutputRestrictions{
AllowQuotes: false,
MaxQuoteLength: 0,
RequireCitation: true,
AllowCopy: false,
AllowExport: false,
},
RiskScore: 0,
}
// If no licensed content, return early with no restrictions
if !facts.Present {
result.EffectiveMode = "UNRESTRICTED"
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: true,
MaxQuoteLength: -1, // unlimited
RequireCitation: false,
AllowCopy: true,
AllowExport: true,
}
return result
}
// Evaluate based on operation mode and license status
switch facts.OperationMode {
case "LINK_ONLY":
e.evaluateLinkOnlyMode(facts, result)
case "NOTES_ONLY":
e.evaluateNotesOnlyMode(facts, result)
case "EXCERPT_ONLY":
e.evaluateExcerptOnlyMode(facts, result)
case "FULLTEXT_RAG":
e.evaluateFulltextRAGMode(facts, result)
case "TRAINING":
e.evaluateTrainingMode(facts, result)
default:
// Unknown mode, default to LINK_ONLY
e.evaluateLinkOnlyMode(facts, result)
}
// Check publisher-specific restrictions
e.applyPublisherRestrictions(facts, result)
// Check distribution scope vs license type
e.checkDistributionScope(facts, result)
return result
}
// evaluateLinkOnlyMode - safest mode, always allowed
func (e *LicensePolicyEngine) evaluateLinkOnlyMode(facts *LicensedContentFacts, result *LicensePolicyResult) {
result.EffectiveMode = "LINK_ONLY"
result.Allowed = true
result.Reason = "Link-only Modus ist ohne spezielle Lizenz erlaubt"
result.RiskScore = 0
// Very restrictive output
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: false,
MaxQuoteLength: 0,
RequireCitation: true,
AllowCopy: false,
AllowExport: false,
}
// Recommend control for proper setup
result.RequiredControls = append(result.RequiredControls, LicenseControl{
ID: "CTRL-LINK-ONLY-MODE",
Title: "Link-only / Evidence Navigator aktivieren",
Description: "Nur Verweise und Checklisten, kein Volltext",
WhatToDo: "System auf LINK_ONLY konfigurieren, keine Normtexte indexieren",
Evidence: []string{"System-Konfiguration", "Stichproben-Audit"},
})
}
// evaluateNotesOnlyMode - customer notes, usually allowed
func (e *LicensePolicyEngine) evaluateNotesOnlyMode(facts *LicensedContentFacts, result *LicensePolicyResult) {
result.EffectiveMode = "NOTES_ONLY"
result.Allowed = true
result.Reason = "Notes-only Modus mit kundeneigenen Zusammenfassungen"
result.RiskScore = 10
// Allow paraphrased content
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: false, // No direct quotes from norms
MaxQuoteLength: 0,
RequireCitation: true,
AllowCopy: true, // Can copy own notes
AllowExport: true, // Can export own notes
}
result.RequiredControls = append(result.RequiredControls, LicenseControl{
ID: "CTRL-NOTES-ONLY-RAG",
Title: "Notes-only RAG (kundeneigene Paraphrasen)",
Description: "Nur kundeneigene Zusammenfassungen indexieren",
WhatToDo: "UI-Flow fuer Notes-Erstellung, kein Copy/Paste von Originaltexten",
Evidence: []string{"Notes-Provenance-Log", "Stichproben"},
})
// Add gap if license type is unknown
if facts.LicenseType == "UNKNOWN" {
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_LICENSE_UNKNOWN",
Title: "Lizenzlage unklar",
Description: "Die Lizenzlage sollte geklaert werden",
Controls: []string{"CTRL-LICENSE-PROOF"},
Severity: "WARN",
})
result.EscalationLevel = "E2"
}
}
// evaluateExcerptOnlyMode - short quotes under citation rights
func (e *LicensePolicyEngine) evaluateExcerptOnlyMode(facts *LicensedContentFacts, result *LicensePolicyResult) {
result.EffectiveMode = "EXCERPT_ONLY"
result.RiskScore = 30
// Check if AI use is permitted
if facts.AIUsePermitted == "NO" || facts.AIUsePermitted == "UNKNOWN" {
// Downgrade to NOTES_ONLY
result.EffectiveMode = "NOTES_ONLY"
result.Reason = "Excerpt-Modus nicht erlaubt ohne AI-Freigabe, Downgrade auf Notes-only"
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_AI_USE_NOT_PERMITTED",
Title: "AI/TDM-Nutzung nicht erlaubt",
Description: "Zitate erfordern AI-Nutzungserlaubnis",
Controls: []string{"CTRL-LICENSE-PROOF", "CTRL-NOTES-ONLY-RAG"},
Severity: "WARN",
})
result.EscalationLevel = "E2"
} else {
result.Allowed = true
result.Reason = "Kurze Zitate im Rahmen des Zitatrechts"
}
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: facts.AIUsePermitted == "YES",
MaxQuoteLength: 150, // Max 150 characters per quote
RequireCitation: true,
AllowCopy: false,
AllowExport: false,
}
result.RequiredControls = append(result.RequiredControls, LicenseControl{
ID: "CTRL-OUTPUT-GUARD-QUOTES",
Title: "Output-Guard: Quote-Limits",
Description: "Zitatlänge begrenzen",
WhatToDo: "Max. 150 Zeichen pro Zitat, immer mit Quellenangabe",
Evidence: []string{"Output-Guard-Konfiguration"},
})
}
// evaluateFulltextRAGMode - requires explicit license proof
func (e *LicensePolicyEngine) evaluateFulltextRAGMode(facts *LicensedContentFacts, result *LicensePolicyResult) {
result.RiskScore = 60
// Check if AI use is explicitly permitted AND proof is uploaded
if facts.AIUsePermitted == "YES" && facts.ProofUploaded {
result.EffectiveMode = "FULLTEXT_RAG"
result.Allowed = true
result.Reason = "Volltext-RAG mit nachgewiesener AI-Lizenz"
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: true,
MaxQuoteLength: 500, // Still limited
RequireCitation: true,
AllowCopy: false, // No copy to prevent redistribution
AllowExport: false, // No export
}
result.RequiredControls = append(result.RequiredControls,
LicenseControl{
ID: "CTRL-LICENSE-GATED-INGEST",
Title: "License-gated Ingest",
Description: "Technische Durchsetzung der Lizenzpruefung",
WhatToDo: "Ingest-Pipeline prueft Lizenz vor Indexierung",
Evidence: []string{"Ingest-Audit-Logs"},
},
LicenseControl{
ID: "CTRL-TENANT-ISOLATION-STANDARDS",
Title: "Tenant-Isolation",
Description: "Strikte Trennung lizenzierter Inhalte",
WhatToDo: "Keine Cross-Tenant-Suche, kein Export",
Evidence: []string{"Tenant-Isolation-Dokumentation"},
},
)
} else {
// NOT ALLOWED - downgrade to LINK_ONLY
result.Allowed = false
result.EffectiveMode = "LINK_ONLY"
result.Reason = "Volltext-RAG ohne Lizenznachweis nicht erlaubt"
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_FULLTEXT_WITHOUT_PROOF",
Title: "Volltext-RAG ohne Lizenznachweis",
Description: "Volltext-RAG erfordert nachgewiesene AI-Nutzungserlaubnis",
Controls: []string{"CTRL-LICENSE-PROOF", "CTRL-LINK-ONLY-MODE"},
Severity: "BLOCK",
})
result.EscalationLevel = "E3"
// Set stop line
result.StopLine = &LicenseStopLine{
ID: "STOP_FULLTEXT_WITHOUT_PROOF",
Title: "Volltext-RAG blockiert",
Message: "Volltext-RAG erfordert einen Nachweis der AI-Nutzungserlaubnis. Bitte laden Sie den Lizenzvertrag hoch oder wechseln Sie auf Link-only Modus.",
Outcome: "NOT_ALLOWED_UNTIL_LICENSE_CLEARED",
}
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: false,
MaxQuoteLength: 0,
RequireCitation: true,
AllowCopy: false,
AllowExport: false,
}
}
}
// evaluateTrainingMode - most restrictive, rarely allowed
func (e *LicensePolicyEngine) evaluateTrainingMode(facts *LicensedContentFacts, result *LicensePolicyResult) {
result.RiskScore = 80
// Training is almost always blocked for standards
if facts.AIUsePermitted == "YES" && facts.ProofUploaded && facts.LicenseType == "AI_LICENSE" {
result.EffectiveMode = "TRAINING"
result.Allowed = true
result.Reason = "Training mit expliziter AI-Training-Lizenz"
result.EscalationLevel = "E3" // Still requires review
} else {
// HARD BLOCK
result.Allowed = false
result.EffectiveMode = "LINK_ONLY"
result.Reason = "Training auf Standards ohne explizite AI-Training-Lizenz verboten"
result.StopLine = &LicenseStopLine{
ID: "STOP_TRAINING_WITHOUT_PROOF",
Title: "Training blockiert",
Message: "Modell-Training mit lizenzierten Standards ist ohne explizite AI-Training-Lizenz nicht zulaessig. DIN Media hat dies ausdruecklich ausgeschlossen.",
Outcome: "NOT_ALLOWED",
}
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_TRAINING_ON_STANDARDS",
Title: "Training auf Standards verboten",
Description: "Modell-Training erfordert explizite AI-Training-Lizenz",
Controls: []string{"CTRL-LICENSE-PROOF"},
Severity: "BLOCK",
})
result.EscalationLevel = "E3"
}
result.OutputRestrictions = &OutputRestrictions{
AllowQuotes: false,
MaxQuoteLength: 0,
RequireCitation: true,
AllowCopy: false,
AllowExport: false,
}
}
// applyPublisherRestrictions applies publisher-specific rules
func (e *LicensePolicyEngine) applyPublisherRestrictions(facts *LicensedContentFacts, result *LicensePolicyResult) {
// DIN Media specific restrictions
if facts.Publisher == "DIN_MEDIA" {
if facts.AIUsePermitted != "YES" {
// DIN Media explicitly prohibits AI use without license
if facts.OperationMode == "FULLTEXT_RAG" || facts.OperationMode == "TRAINING" {
result.Allowed = false
result.EffectiveMode = "LINK_ONLY"
result.StopLine = &LicenseStopLine{
ID: "STOP_DIN_FULLTEXT_AI_NOT_ALLOWED",
Title: "DIN Media AI-Nutzung blockiert",
Message: "DIN Media untersagt die AI-Nutzung von Normen ohne explizite Genehmigung. Ein AI-Lizenzmodell ist erst ab Q4/2025 geplant. Bitte nutzen Sie Link-only oder Notes-only Modus.",
Outcome: "NOT_ALLOWED_UNTIL_LICENSE_CLEARED",
}
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_DIN_MEDIA_WITHOUT_AI_LICENSE",
Title: "DIN Media ohne AI-Lizenz",
Description: "DIN Media verbietet AI-Nutzung ohne explizite Genehmigung",
Controls: []string{"CTRL-LINK-ONLY-MODE", "CTRL-NO-CRAWLING-DIN"},
Severity: "BLOCK",
})
result.EscalationLevel = "E3"
result.RiskScore = 70
}
}
// Always add no-crawling control for DIN Media
result.RequiredControls = append(result.RequiredControls, LicenseControl{
ID: "CTRL-NO-CRAWLING-DIN",
Title: "Crawler-Block fuer DIN Media",
Description: "Keine automatisierten Abrufe von DIN-Normen-Portalen",
WhatToDo: "Domain-Denylist konfigurieren, nur manueller Import",
Evidence: []string{"Domain-Denylist", "Fetch-Logs"},
})
}
}
// checkDistributionScope checks if distribution scope matches license type
func (e *LicensePolicyEngine) checkDistributionScope(facts *LicensedContentFacts, result *LicensePolicyResult) {
// Single workstation license with broad distribution
if facts.LicenseType == "SINGLE_WORKSTATION" {
if facts.DistributionScope == "COMPANY_INTERNAL" ||
facts.DistributionScope == "SUBSIDIARIES" ||
facts.DistributionScope == "EXTERNAL_CUSTOMERS" {
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_DISTRIBUTION_SCOPE_MISMATCH",
Title: "Verteilungsumfang uebersteigt Lizenz",
Description: "Einzelplatz-Lizenz erlaubt keine unternehmensweite Nutzung",
Controls: []string{"CTRL-LICENSE-PROOF", "CTRL-LINK-ONLY-MODE"},
Severity: "WARN",
})
result.EscalationLevel = "E3"
result.RiskScore += 20
}
}
// Network license with external distribution
if facts.LicenseType == "NETWORK_INTRANET" {
if facts.DistributionScope == "EXTERNAL_CUSTOMERS" {
result.Gaps = append(result.Gaps, LicenseGap{
ID: "GAP_DISTRIBUTION_SCOPE_EXTERNAL",
Title: "Externe Verteilung mit Intranet-Lizenz",
Description: "Intranet-Lizenz erlaubt keine externe Verteilung",
Controls: []string{"CTRL-LICENSE-PROOF"},
Severity: "WARN",
})
result.EscalationLevel = "E2"
result.RiskScore += 15
}
}
}
// CanIngestFulltext checks if fulltext ingestion is allowed
func (e *LicensePolicyEngine) CanIngestFulltext(facts *LicensedContentFacts) bool {
if !facts.Present {
return true // No licensed content, no restrictions
}
switch facts.OperationMode {
case "LINK_ONLY":
return false // Only metadata/references
case "NOTES_ONLY":
return false // Only customer notes, not fulltext
case "EXCERPT_ONLY":
return false // Only short excerpts
case "FULLTEXT_RAG":
return facts.AIUsePermitted == "YES" && facts.ProofUploaded
case "TRAINING":
return facts.AIUsePermitted == "YES" && facts.ProofUploaded && facts.LicenseType == "AI_LICENSE"
default:
return false
}
}
// CanIngestNotes checks if customer notes can be ingested
func (e *LicensePolicyEngine) CanIngestNotes(facts *LicensedContentFacts) bool {
if !facts.Present {
return true
}
// Notes are allowed in most modes
return facts.OperationMode == "NOTES_ONLY" ||
facts.OperationMode == "EXCERPT_ONLY" ||
facts.OperationMode == "FULLTEXT_RAG" ||
facts.OperationMode == "TRAINING"
}
// GetEffectiveMode returns the effective operation mode after policy evaluation
func (e *LicensePolicyEngine) GetEffectiveMode(facts *LicensedContentFacts) string {
result := e.Evaluate(facts)
return result.EffectiveMode
}
// LicenseIngestDecision represents the decision for ingesting a document
type LicenseIngestDecision struct {
AllowFulltext bool `json:"allow_fulltext"`
AllowNotes bool `json:"allow_notes"`
AllowMetadata bool `json:"allow_metadata"`
Reason string `json:"reason"`
EffectiveMode string `json:"effective_mode"`
}
// DecideIngest returns the ingest decision for a document
func (e *LicensePolicyEngine) DecideIngest(facts *LicensedContentFacts) *LicenseIngestDecision {
result := e.Evaluate(facts)
decision := &LicenseIngestDecision{
AllowMetadata: true, // Metadata is always allowed
AllowNotes: e.CanIngestNotes(facts),
AllowFulltext: e.CanIngestFulltext(facts),
Reason: result.Reason,
EffectiveMode: result.EffectiveMode,
}
return decision
}
// LicenseAuditEntry represents an audit log entry for license decisions
type LicenseAuditEntry struct {
Timestamp time.Time `json:"timestamp"`
TenantID string `json:"tenant_id"`
DocumentID string `json:"document_id,omitempty"`
Facts *LicensedContentFacts `json:"facts"`
Decision string `json:"decision"` // ALLOW, DENY, DOWNGRADE
EffectiveMode string `json:"effective_mode"`
Reason string `json:"reason"`
StopLineID string `json:"stop_line_id,omitempty"`
}
// FormatAuditEntry creates an audit entry for logging
func (e *LicensePolicyEngine) FormatAuditEntry(tenantID string, documentID string, facts *LicensedContentFacts, result *LicensePolicyResult) *LicenseAuditEntry {
decision := "ALLOW"
if !result.Allowed {
decision = "DENY"
} else if result.EffectiveMode != facts.OperationMode {
decision = "DOWNGRADE"
}
entry := &LicenseAuditEntry{
Timestamp: time.Now().UTC(),
TenantID: tenantID,
DocumentID: documentID,
Facts: facts,
Decision: decision,
EffectiveMode: result.EffectiveMode,
Reason: result.Reason,
}
if result.StopLine != nil {
entry.StopLineID = result.StopLine.ID
}
return entry
}
// FormatHumanReadableSummary creates a human-readable summary of the evaluation
func (e *LicensePolicyEngine) FormatHumanReadableSummary(result *LicensePolicyResult) string {
var sb strings.Builder
sb.WriteString("=== Lizenz-Policy Bewertung ===\n\n")
if result.Allowed {
sb.WriteString(fmt.Sprintf("Status: ERLAUBT\n"))
} else {
sb.WriteString(fmt.Sprintf("Status: BLOCKIERT\n"))
}
sb.WriteString(fmt.Sprintf("Effektiver Modus: %s\n", result.EffectiveMode))
sb.WriteString(fmt.Sprintf("Risiko-Score: %d\n", result.RiskScore))
sb.WriteString(fmt.Sprintf("Begruendung: %s\n\n", result.Reason))
if result.StopLine != nil {
sb.WriteString("!!! STOP-LINE !!!\n")
sb.WriteString(fmt.Sprintf(" %s: %s\n", result.StopLine.ID, result.StopLine.Title))
sb.WriteString(fmt.Sprintf(" %s\n\n", result.StopLine.Message))
}
if len(result.Gaps) > 0 {
sb.WriteString("Identifizierte Luecken:\n")
for _, gap := range result.Gaps {
sb.WriteString(fmt.Sprintf(" - [%s] %s: %s\n", gap.Severity, gap.ID, gap.Title))
}
sb.WriteString("\n")
}
if len(result.RequiredControls) > 0 {
sb.WriteString("Erforderliche Massnahmen:\n")
for _, ctrl := range result.RequiredControls {
sb.WriteString(fmt.Sprintf(" - %s: %s\n", ctrl.ID, ctrl.Title))
}
sb.WriteString("\n")
}
if result.OutputRestrictions != nil {
sb.WriteString("Output-Einschraenkungen:\n")
sb.WriteString(fmt.Sprintf(" Zitate erlaubt: %v\n", result.OutputRestrictions.AllowQuotes))
sb.WriteString(fmt.Sprintf(" Max. Zitatlaenge: %d Zeichen\n", result.OutputRestrictions.MaxQuoteLength))
sb.WriteString(fmt.Sprintf(" Copy erlaubt: %v\n", result.OutputRestrictions.AllowCopy))
sb.WriteString(fmt.Sprintf(" Export erlaubt: %v\n", result.OutputRestrictions.AllowExport))
}
return sb.String()
}

View File

@@ -0,0 +1,940 @@
package ucca
import (
"testing"
)
// =============================================================================
// License Policy Engine Tests
// =============================================================================
func TestNewLicensePolicyEngine(t *testing.T) {
engine := NewLicensePolicyEngine()
if engine == nil {
t.Fatal("Expected non-nil engine")
}
}
// =============================================================================
// Basic Evaluation Tests
// =============================================================================
func TestLicensePolicyEngine_NoLicensedContent(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: false,
}
result := engine.Evaluate(facts)
if result.EffectiveMode != "UNRESTRICTED" {
t.Errorf("Expected UNRESTRICTED mode for no licensed content, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for no licensed content")
}
if !result.OutputRestrictions.AllowQuotes {
t.Error("Expected AllowQuotes=true for no licensed content")
}
if !result.OutputRestrictions.AllowCopy {
t.Error("Expected AllowCopy=true for no licensed content")
}
if !result.OutputRestrictions.AllowExport {
t.Error("Expected AllowExport=true for no licensed content")
}
}
// =============================================================================
// Operation Mode Tests
// =============================================================================
func TestLicensePolicyEngine_LinkOnlyMode(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "UNKNOWN",
OperationMode: "LINK_ONLY",
}
result := engine.Evaluate(facts)
if result.EffectiveMode != "LINK_ONLY" {
t.Errorf("Expected LINK_ONLY mode, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for LINK_ONLY mode")
}
if result.RiskScore != 0 {
t.Errorf("Expected risk score 0 for LINK_ONLY, got %d", result.RiskScore)
}
if result.OutputRestrictions.AllowQuotes {
t.Error("Expected AllowQuotes=false for LINK_ONLY")
}
if result.OutputRestrictions.AllowCopy {
t.Error("Expected AllowCopy=false for LINK_ONLY")
}
}
func TestLicensePolicyEngine_NotesOnlyMode(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "ISO",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "NO",
OperationMode: "NOTES_ONLY",
}
result := engine.Evaluate(facts)
if result.EffectiveMode != "NOTES_ONLY" {
t.Errorf("Expected NOTES_ONLY mode, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for NOTES_ONLY mode")
}
if result.RiskScore != 10 {
t.Errorf("Expected risk score 10 for NOTES_ONLY, got %d", result.RiskScore)
}
// Notes can be copied (they are customer's own)
if !result.OutputRestrictions.AllowCopy {
t.Error("Expected AllowCopy=true for NOTES_ONLY")
}
}
func TestLicensePolicyEngine_NotesOnlyMode_UnknownLicense(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDE",
LicenseType: "UNKNOWN",
AIUsePermitted: "UNKNOWN",
OperationMode: "NOTES_ONLY",
}
result := engine.Evaluate(facts)
// Should have escalation level E2
if result.EscalationLevel != "E2" {
t.Errorf("Expected escalation level E2 for unknown license, got %s", result.EscalationLevel)
}
// Should have GAP_LICENSE_UNKNOWN gap
hasGap := false
for _, gap := range result.Gaps {
if gap.ID == "GAP_LICENSE_UNKNOWN" {
hasGap = true
break
}
}
if !hasGap {
t.Error("Expected GAP_LICENSE_UNKNOWN gap for unknown license")
}
}
func TestLicensePolicyEngine_ExcerptOnlyMode_WithAIPermission(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "ISO",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "YES",
OperationMode: "EXCERPT_ONLY",
}
result := engine.Evaluate(facts)
if result.EffectiveMode != "EXCERPT_ONLY" {
t.Errorf("Expected EXCERPT_ONLY mode, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for EXCERPT_ONLY with AI permission")
}
// Short quotes should be allowed
if !result.OutputRestrictions.AllowQuotes {
t.Error("Expected AllowQuotes=true for EXCERPT_ONLY with AI permission")
}
// But limited to 150 chars
if result.OutputRestrictions.MaxQuoteLength != 150 {
t.Errorf("Expected MaxQuoteLength=150, got %d", result.OutputRestrictions.MaxQuoteLength)
}
}
func TestLicensePolicyEngine_ExcerptOnlyMode_WithoutAIPermission(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "ISO",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "NO",
OperationMode: "EXCERPT_ONLY",
}
result := engine.Evaluate(facts)
// Should be downgraded to NOTES_ONLY
if result.EffectiveMode != "NOTES_ONLY" {
t.Errorf("Expected NOTES_ONLY mode (downgraded), got %s", result.EffectiveMode)
}
// Should have GAP_AI_USE_NOT_PERMITTED
hasGap := false
for _, gap := range result.Gaps {
if gap.ID == "GAP_AI_USE_NOT_PERMITTED" {
hasGap = true
break
}
}
if !hasGap {
t.Error("Expected GAP_AI_USE_NOT_PERMITTED gap")
}
}
func TestLicensePolicyEngine_FulltextRAGMode_Allowed(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "AI_LICENSE",
AIUsePermitted: "YES",
ProofUploaded: true,
OperationMode: "FULLTEXT_RAG",
}
result := engine.Evaluate(facts)
if result.EffectiveMode != "FULLTEXT_RAG" {
t.Errorf("Expected FULLTEXT_RAG mode, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for FULLTEXT_RAG with proof")
}
if result.StopLine != nil {
t.Error("Expected no stop line for allowed FULLTEXT_RAG")
}
// Quotes allowed but limited
if !result.OutputRestrictions.AllowQuotes {
t.Error("Expected AllowQuotes=true for FULLTEXT_RAG")
}
if result.OutputRestrictions.MaxQuoteLength != 500 {
t.Errorf("Expected MaxQuoteLength=500, got %d", result.OutputRestrictions.MaxQuoteLength)
}
}
func TestLicensePolicyEngine_FulltextRAGMode_Blocked_NoProof(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "YES",
ProofUploaded: false, // No proof!
OperationMode: "FULLTEXT_RAG",
}
result := engine.Evaluate(facts)
// Should be blocked and downgraded to LINK_ONLY
if result.Allowed {
t.Error("Expected allowed=false for FULLTEXT_RAG without proof")
}
if result.EffectiveMode != "LINK_ONLY" {
t.Errorf("Expected LINK_ONLY mode (downgraded), got %s", result.EffectiveMode)
}
// Should have stop line
if result.StopLine == nil {
t.Fatal("Expected stop line for blocked FULLTEXT_RAG")
}
if result.StopLine.ID != "STOP_FULLTEXT_WITHOUT_PROOF" {
t.Errorf("Expected stop line STOP_FULLTEXT_WITHOUT_PROOF, got %s", result.StopLine.ID)
}
// Should have GAP_FULLTEXT_WITHOUT_PROOF
hasGap := false
for _, gap := range result.Gaps {
if gap.ID == "GAP_FULLTEXT_WITHOUT_PROOF" {
hasGap = true
if gap.Severity != "BLOCK" {
t.Error("Expected gap severity BLOCK")
}
break
}
}
if !hasGap {
t.Error("Expected GAP_FULLTEXT_WITHOUT_PROOF gap")
}
if result.EscalationLevel != "E3" {
t.Errorf("Expected escalation level E3, got %s", result.EscalationLevel)
}
}
func TestLicensePolicyEngine_TrainingMode_Blocked(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "NO",
ProofUploaded: false,
OperationMode: "TRAINING",
}
result := engine.Evaluate(facts)
if result.Allowed {
t.Error("Expected allowed=false for TRAINING without AI license")
}
if result.EffectiveMode != "LINK_ONLY" {
t.Errorf("Expected LINK_ONLY mode (downgraded), got %s", result.EffectiveMode)
}
// Should have stop line
if result.StopLine == nil {
t.Fatal("Expected stop line for blocked TRAINING")
}
if result.EscalationLevel != "E3" {
t.Errorf("Expected escalation level E3, got %s", result.EscalationLevel)
}
}
func TestLicensePolicyEngine_TrainingMode_Allowed(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "AI_LICENSE",
AIUsePermitted: "YES",
ProofUploaded: true,
OperationMode: "TRAINING",
}
result := engine.Evaluate(facts)
if result.EffectiveMode != "TRAINING" {
t.Errorf("Expected TRAINING mode, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for TRAINING with AI_LICENSE")
}
// Still requires E3 review
if result.EscalationLevel != "E3" {
t.Errorf("Expected escalation level E3 even for allowed training, got %s", result.EscalationLevel)
}
}
// =============================================================================
// Publisher-Specific Tests (DIN Media)
// =============================================================================
func TestLicensePolicyEngine_DINMedia_FulltextBlocked(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "NO",
ProofUploaded: false,
OperationMode: "FULLTEXT_RAG",
}
result := engine.Evaluate(facts)
if result.Allowed {
t.Error("Expected allowed=false for DIN_MEDIA FULLTEXT_RAG without AI permission")
}
// Should have DIN-specific stop line
if result.StopLine == nil {
t.Fatal("Expected stop line for DIN_MEDIA")
}
if result.StopLine.ID != "STOP_DIN_FULLTEXT_AI_NOT_ALLOWED" {
t.Errorf("Expected stop line STOP_DIN_FULLTEXT_AI_NOT_ALLOWED, got %s", result.StopLine.ID)
}
// Should have CTRL-NO-CRAWLING-DIN control
hasControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "CTRL-NO-CRAWLING-DIN" {
hasControl = true
break
}
}
if !hasControl {
t.Error("Expected CTRL-NO-CRAWLING-DIN control for DIN_MEDIA")
}
}
func TestLicensePolicyEngine_DINMedia_LinkOnlyAllowed(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "NO",
OperationMode: "LINK_ONLY",
}
result := engine.Evaluate(facts)
if !result.Allowed {
t.Error("Expected allowed=true for DIN_MEDIA LINK_ONLY")
}
if result.EffectiveMode != "LINK_ONLY" {
t.Errorf("Expected LINK_ONLY mode, got %s", result.EffectiveMode)
}
// Should have no stop line for LINK_ONLY
if result.StopLine != nil {
t.Error("Expected no stop line for DIN_MEDIA LINK_ONLY")
}
}
func TestLicensePolicyEngine_DINMedia_NotesOnlyAllowed(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "NO",
OperationMode: "NOTES_ONLY",
}
result := engine.Evaluate(facts)
if !result.Allowed {
t.Error("Expected allowed=true for DIN_MEDIA NOTES_ONLY")
}
if result.EffectiveMode != "NOTES_ONLY" {
t.Errorf("Expected NOTES_ONLY mode, got %s", result.EffectiveMode)
}
}
// =============================================================================
// Distribution Scope Tests
// =============================================================================
func TestLicensePolicyEngine_DistributionScopeMismatch(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "ISO",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "NO",
OperationMode: "LINK_ONLY",
DistributionScope: "COMPANY_INTERNAL",
}
result := engine.Evaluate(facts)
// Should have GAP_DISTRIBUTION_SCOPE_MISMATCH
hasGap := false
for _, gap := range result.Gaps {
if gap.ID == "GAP_DISTRIBUTION_SCOPE_MISMATCH" {
hasGap = true
break
}
}
if !hasGap {
t.Error("Expected GAP_DISTRIBUTION_SCOPE_MISMATCH for single workstation with company distribution")
}
if result.EscalationLevel != "E3" {
t.Errorf("Expected escalation level E3, got %s", result.EscalationLevel)
}
}
func TestLicensePolicyEngine_NetworkLicenseExternalDistribution(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "YES",
ProofUploaded: true,
OperationMode: "NOTES_ONLY",
DistributionScope: "EXTERNAL_CUSTOMERS",
}
result := engine.Evaluate(facts)
// Should have GAP_DISTRIBUTION_SCOPE_EXTERNAL
hasGap := false
for _, gap := range result.Gaps {
if gap.ID == "GAP_DISTRIBUTION_SCOPE_EXTERNAL" {
hasGap = true
break
}
}
if !hasGap {
t.Error("Expected GAP_DISTRIBUTION_SCOPE_EXTERNAL for network license with external distribution")
}
}
// =============================================================================
// Helper Function Tests
// =============================================================================
func TestLicensePolicyEngine_CanIngestFulltext(t *testing.T) {
engine := NewLicensePolicyEngine()
tests := []struct {
name string
facts *LicensedContentFacts
expected bool
}{
{
name: "No licensed content",
facts: &LicensedContentFacts{
Present: false,
},
expected: true,
},
{
name: "LINK_ONLY mode",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "LINK_ONLY",
},
expected: false,
},
{
name: "NOTES_ONLY mode",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "NOTES_ONLY",
},
expected: false,
},
{
name: "FULLTEXT_RAG with proof",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "FULLTEXT_RAG",
AIUsePermitted: "YES",
ProofUploaded: true,
},
expected: true,
},
{
name: "FULLTEXT_RAG without proof",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "FULLTEXT_RAG",
AIUsePermitted: "YES",
ProofUploaded: false,
},
expected: false,
},
{
name: "TRAINING with AI_LICENSE",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "TRAINING",
AIUsePermitted: "YES",
ProofUploaded: true,
LicenseType: "AI_LICENSE",
},
expected: true,
},
{
name: "TRAINING without AI_LICENSE",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "TRAINING",
AIUsePermitted: "YES",
ProofUploaded: true,
LicenseType: "NETWORK_INTRANET",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := engine.CanIngestFulltext(tt.facts)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestLicensePolicyEngine_CanIngestNotes(t *testing.T) {
engine := NewLicensePolicyEngine()
tests := []struct {
name string
facts *LicensedContentFacts
expected bool
}{
{
name: "No licensed content",
facts: &LicensedContentFacts{
Present: false,
},
expected: true,
},
{
name: "LINK_ONLY mode",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "LINK_ONLY",
},
expected: false,
},
{
name: "NOTES_ONLY mode",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "NOTES_ONLY",
},
expected: true,
},
{
name: "EXCERPT_ONLY mode",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "EXCERPT_ONLY",
},
expected: true,
},
{
name: "FULLTEXT_RAG mode",
facts: &LicensedContentFacts{
Present: true,
OperationMode: "FULLTEXT_RAG",
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := engine.CanIngestNotes(tt.facts)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestLicensePolicyEngine_GetEffectiveMode(t *testing.T) {
engine := NewLicensePolicyEngine()
// Test downgrade scenario
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "NO",
OperationMode: "FULLTEXT_RAG",
}
effectiveMode := engine.GetEffectiveMode(facts)
if effectiveMode != "LINK_ONLY" {
t.Errorf("Expected effective mode LINK_ONLY (downgraded), got %s", effectiveMode)
}
}
func TestLicensePolicyEngine_DecideIngest(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "AI_LICENSE",
AIUsePermitted: "YES",
ProofUploaded: true,
OperationMode: "FULLTEXT_RAG",
}
decision := engine.DecideIngest(facts)
if !decision.AllowMetadata {
t.Error("Expected AllowMetadata=true")
}
if !decision.AllowNotes {
t.Error("Expected AllowNotes=true")
}
if !decision.AllowFulltext {
t.Error("Expected AllowFulltext=true for FULLTEXT_RAG with proof")
}
if decision.EffectiveMode != "FULLTEXT_RAG" {
t.Errorf("Expected EffectiveMode=FULLTEXT_RAG, got %s", decision.EffectiveMode)
}
}
// =============================================================================
// Audit Entry Tests
// =============================================================================
func TestLicensePolicyEngine_FormatAuditEntry(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "NO",
OperationMode: "FULLTEXT_RAG",
}
result := engine.Evaluate(facts)
entry := engine.FormatAuditEntry("tenant-123", "doc-456", facts, result)
if entry.TenantID != "tenant-123" {
t.Errorf("Expected TenantID=tenant-123, got %s", entry.TenantID)
}
if entry.DocumentID != "doc-456" {
t.Errorf("Expected DocumentID=doc-456, got %s", entry.DocumentID)
}
if entry.Decision != "DENY" {
t.Errorf("Expected Decision=DENY, got %s", entry.Decision)
}
if entry.StopLineID == "" {
t.Error("Expected StopLineID to be set for denied request")
}
}
func TestLicensePolicyEngine_FormatAuditEntry_Downgrade(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "ISO",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "NO",
OperationMode: "EXCERPT_ONLY",
}
result := engine.Evaluate(facts)
entry := engine.FormatAuditEntry("tenant-123", "doc-456", facts, result)
if entry.Decision != "DOWNGRADE" {
t.Errorf("Expected Decision=DOWNGRADE, got %s", entry.Decision)
}
if entry.EffectiveMode != "NOTES_ONLY" {
t.Errorf("Expected EffectiveMode=NOTES_ONLY, got %s", entry.EffectiveMode)
}
}
// =============================================================================
// Human Readable Summary Tests
// =============================================================================
func TestLicensePolicyEngine_FormatHumanReadableSummary(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "SINGLE_WORKSTATION",
AIUsePermitted: "NO",
OperationMode: "FULLTEXT_RAG",
}
result := engine.Evaluate(facts)
summary := engine.FormatHumanReadableSummary(result)
// Should contain key elements
if !stringContains(summary, "BLOCKIERT") {
t.Error("Summary should contain 'BLOCKIERT'")
}
if !stringContains(summary, "LINK_ONLY") {
t.Error("Summary should contain 'LINK_ONLY'")
}
if !stringContains(summary, "STOP-LINE") {
t.Error("Summary should contain '!!! STOP-LINE !!!'")
}
if !stringContains(summary, "DIN Media") {
t.Error("Summary should mention DIN Media")
}
}
// =============================================================================
// Determinism Tests
// =============================================================================
func TestLicensePolicyEngine_Determinism(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "DIN_MEDIA",
LicenseType: "NETWORK_INTRANET",
AIUsePermitted: "NO",
ProofUploaded: false,
OperationMode: "FULLTEXT_RAG",
DistributionScope: "COMPANY_INTERNAL",
}
// Run evaluation 10 times and ensure identical results
firstResult := engine.Evaluate(facts)
for i := 0; i < 10; i++ {
result := engine.Evaluate(facts)
if result.Allowed != firstResult.Allowed {
t.Errorf("Run %d: Allowed mismatch: %v vs %v", i, result.Allowed, firstResult.Allowed)
}
if result.EffectiveMode != firstResult.EffectiveMode {
t.Errorf("Run %d: EffectiveMode mismatch: %s vs %s", i, result.EffectiveMode, firstResult.EffectiveMode)
}
if result.RiskScore != firstResult.RiskScore {
t.Errorf("Run %d: RiskScore mismatch: %d vs %d", i, result.RiskScore, firstResult.RiskScore)
}
if len(result.Gaps) != len(firstResult.Gaps) {
t.Errorf("Run %d: Gaps count mismatch: %d vs %d", i, len(result.Gaps), len(firstResult.Gaps))
}
}
}
// =============================================================================
// Edge Case Tests
// =============================================================================
func TestLicensePolicyEngine_UnknownOperationMode(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{
Present: true,
Publisher: "ISO",
OperationMode: "INVALID_MODE",
}
result := engine.Evaluate(facts)
// Should default to LINK_ONLY
if result.EffectiveMode != "LINK_ONLY" {
t.Errorf("Expected default to LINK_ONLY for unknown mode, got %s", result.EffectiveMode)
}
if !result.Allowed {
t.Error("Expected allowed=true for fallback to LINK_ONLY")
}
}
func TestLicensePolicyEngine_EmptyFacts(t *testing.T) {
engine := NewLicensePolicyEngine()
facts := &LicensedContentFacts{}
result := engine.Evaluate(facts)
// Empty facts = no licensed content
if result.EffectiveMode != "UNRESTRICTED" {
t.Errorf("Expected UNRESTRICTED for empty facts, got %s", result.EffectiveMode)
}
}
// =============================================================================
// Risk Score Tests
// =============================================================================
func TestLicensePolicyEngine_RiskScores(t *testing.T) {
engine := NewLicensePolicyEngine()
tests := []struct {
name string
mode string
expectedMin int
expectedMax int
}{
{"LINK_ONLY", "LINK_ONLY", 0, 0},
{"NOTES_ONLY", "NOTES_ONLY", 10, 30},
{"EXCERPT_ONLY", "EXCERPT_ONLY", 30, 50},
{"FULLTEXT_RAG", "FULLTEXT_RAG", 60, 90},
{"TRAINING", "TRAINING", 80, 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
facts := &LicensedContentFacts{
Present: true,
Publisher: "VDI",
LicenseType: "AI_LICENSE",
AIUsePermitted: "YES",
ProofUploaded: true,
OperationMode: tt.mode,
}
result := engine.Evaluate(facts)
if result.RiskScore < tt.expectedMin || result.RiskScore > tt.expectedMax {
t.Errorf("Expected risk score in range [%d, %d], got %d",
tt.expectedMin, tt.expectedMax, result.RiskScore)
}
})
}
}
// Helper function
func stringContains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContainsHelper(s, substr))
}
func stringContainsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -0,0 +1,523 @@
package ucca
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// Feasibility represents the overall assessment result
type Feasibility string
const (
FeasibilityYES Feasibility = "YES"
FeasibilityCONDITIONAL Feasibility = "CONDITIONAL"
FeasibilityNO Feasibility = "NO"
)
// RiskLevel represents the overall risk classification
type RiskLevel string
const (
RiskLevelMINIMAL RiskLevel = "MINIMAL"
RiskLevelLOW RiskLevel = "LOW"
RiskLevelMEDIUM RiskLevel = "MEDIUM"
RiskLevelHIGH RiskLevel = "HIGH"
RiskLevelUNACCEPTABLE RiskLevel = "UNACCEPTABLE"
)
// Complexity represents implementation complexity
type Complexity string
const (
ComplexityLOW Complexity = "LOW"
ComplexityMEDIUM Complexity = "MEDIUM"
ComplexityHIGH Complexity = "HIGH"
)
// Severity represents rule severity
type Severity string
const (
SeverityINFO Severity = "INFO"
SeverityWARN Severity = "WARN"
SeverityBLOCK Severity = "BLOCK"
)
// Domain represents the business domain
type Domain string
const (
// Industrie & Produktion
DomainAutomotive Domain = "automotive"
DomainMechanicalEngineering Domain = "mechanical_engineering"
DomainPlantEngineering Domain = "plant_engineering"
DomainElectricalEngineering Domain = "electrical_engineering"
DomainAerospace Domain = "aerospace"
DomainChemicals Domain = "chemicals"
DomainFoodBeverage Domain = "food_beverage"
DomainTextiles Domain = "textiles"
DomainPackaging Domain = "packaging"
// Energie & Versorgung
DomainUtilities Domain = "utilities"
DomainEnergy Domain = "energy"
DomainOilGas Domain = "oil_gas"
// Land- & Forstwirtschaft
DomainAgriculture Domain = "agriculture"
DomainForestry Domain = "forestry"
DomainFishing Domain = "fishing"
// Bau & Immobilien
DomainConstruction Domain = "construction"
DomainRealEstate Domain = "real_estate"
DomainFacilityManagement Domain = "facility_management"
// Gesundheit & Soziales
DomainHealthcare Domain = "healthcare"
DomainMedicalDevices Domain = "medical_devices"
DomainPharma Domain = "pharma"
DomainElderlyCare Domain = "elderly_care"
DomainSocialServices Domain = "social_services"
// Bildung & Forschung
DomainEducation Domain = "education"
DomainHigherEducation Domain = "higher_education"
DomainVocationalTraining Domain = "vocational_training"
DomainResearch Domain = "research"
// Finanzen & Versicherung
DomainFinance Domain = "finance"
DomainBanking Domain = "banking"
DomainInsurance Domain = "insurance"
DomainInvestment Domain = "investment"
// Handel & Logistik
DomainRetail Domain = "retail"
DomainEcommerce Domain = "ecommerce"
DomainWholesale Domain = "wholesale"
DomainLogistics Domain = "logistics"
// IT & Telekommunikation
DomainITServices Domain = "it_services"
DomainTelecom Domain = "telecom"
DomainCybersecurity Domain = "cybersecurity"
// Recht & Beratung
DomainLegal Domain = "legal"
DomainConsulting Domain = "consulting"
DomainTaxAdvisory Domain = "tax_advisory"
// Oeffentlicher Sektor
DomainPublic Domain = "public_sector"
DomainDefense Domain = "defense"
DomainJustice Domain = "justice"
// Marketing & Medien
DomainMarketing Domain = "marketing"
DomainMedia Domain = "media"
DomainEntertainment Domain = "entertainment"
// HR & Personal
DomainHR Domain = "hr"
DomainRecruiting Domain = "recruiting"
// Tourismus & Gastronomie
DomainHospitality Domain = "hospitality"
DomainTourism Domain = "tourism"
// Sonstige
DomainNonprofit Domain = "nonprofit"
DomainSports Domain = "sports"
DomainGeneral Domain = "general"
)
// ValidDomains contains all valid domain values
var ValidDomains = map[Domain]bool{
DomainAutomotive: true, DomainMechanicalEngineering: true, DomainPlantEngineering: true,
DomainElectricalEngineering: true, DomainAerospace: true, DomainChemicals: true,
DomainFoodBeverage: true, DomainTextiles: true, DomainPackaging: true,
DomainUtilities: true, DomainEnergy: true, DomainOilGas: true,
DomainAgriculture: true, DomainForestry: true, DomainFishing: true,
DomainConstruction: true, DomainRealEstate: true, DomainFacilityManagement: true,
DomainHealthcare: true, DomainMedicalDevices: true, DomainPharma: true,
DomainElderlyCare: true, DomainSocialServices: true,
DomainEducation: true, DomainHigherEducation: true, DomainVocationalTraining: true, DomainResearch: true,
DomainFinance: true, DomainBanking: true, DomainInsurance: true, DomainInvestment: true,
DomainRetail: true, DomainEcommerce: true, DomainWholesale: true, DomainLogistics: true,
DomainITServices: true, DomainTelecom: true, DomainCybersecurity: true,
DomainLegal: true, DomainConsulting: true, DomainTaxAdvisory: true,
DomainPublic: true, DomainDefense: true, DomainJustice: true,
DomainMarketing: true, DomainMedia: true, DomainEntertainment: true,
DomainHR: true, DomainRecruiting: true,
DomainHospitality: true, DomainTourism: true,
DomainNonprofit: true, DomainSports: true, DomainGeneral: true,
}
// AutomationLevel represents the degree of automation
type AutomationLevel string
const (
AutomationAssistive AutomationLevel = "assistive"
AutomationSemiAutomated AutomationLevel = "semi_automated"
AutomationFullyAutomated AutomationLevel = "fully_automated"
)
// TrainingAllowed represents if training with data is permitted
type TrainingAllowed string
const (
TrainingYES TrainingAllowed = "YES"
TrainingCONDITIONAL TrainingAllowed = "CONDITIONAL"
TrainingNO TrainingAllowed = "NO"
)
// ============================================================================
// Input Structs
// ============================================================================
// UseCaseIntake represents the user's input describing their planned AI use case
type UseCaseIntake struct {
// Free-text description of the use case
UseCaseText string `json:"use_case_text"`
// Business domain
Domain Domain `json:"domain"`
// Title for the assessment (optional)
Title string `json:"title,omitempty"`
// Data types involved
DataTypes DataTypes `json:"data_types"`
// Purpose of the processing
Purpose Purpose `json:"purpose"`
// Level of automation
Automation AutomationLevel `json:"automation"`
// Output characteristics
Outputs Outputs `json:"outputs"`
// Hosting configuration
Hosting Hosting `json:"hosting"`
// Model usage configuration
ModelUsage ModelUsage `json:"model_usage"`
// Retention configuration
Retention Retention `json:"retention"`
// Financial regulations context (DORA, MaRisk, BAIT)
// Only applicable for financial domains (banking, finance, insurance, investment)
FinancialContext *FinancialContext `json:"financial_context,omitempty"`
// Opt-in to store raw text (otherwise only hash)
StoreRawText bool `json:"store_raw_text,omitempty"`
}
// DataTypes specifies what kinds of data are processed
type DataTypes struct {
PersonalData bool `json:"personal_data"`
Article9Data bool `json:"article_9_data"` // Special categories (health, religion, etc.)
MinorData bool `json:"minor_data"` // Data of children
LicensePlates bool `json:"license_plates"` // KFZ-Kennzeichen
Images bool `json:"images"` // Photos/images of persons
Audio bool `json:"audio"` // Voice recordings
LocationData bool `json:"location_data"` // GPS/location tracking
BiometricData bool `json:"biometric_data"` // Fingerprints, face recognition
FinancialData bool `json:"financial_data"` // Bank accounts, salaries
EmployeeData bool `json:"employee_data"` // HR/employment data
CustomerData bool `json:"customer_data"` // Customer information
PublicData bool `json:"public_data"` // Publicly available data only
}
// Purpose specifies the processing purpose
type Purpose struct {
CustomerSupport bool `json:"customer_support"`
Marketing bool `json:"marketing"`
Analytics bool `json:"analytics"`
Automation bool `json:"automation"`
EvaluationScoring bool `json:"evaluation_scoring"` // Scoring/ranking of persons
DecisionMaking bool `json:"decision_making"` // Automated decisions
Profiling bool `json:"profiling"`
Research bool `json:"research"`
InternalTools bool `json:"internal_tools"`
PublicService bool `json:"public_service"`
}
// Outputs specifies output characteristics
type Outputs struct {
RecommendationsToUsers bool `json:"recommendations_to_users"`
RankingsOrScores bool `json:"rankings_or_scores"` // Outputs rankings/scores
LegalEffects bool `json:"legal_effects"` // Has legal consequences
AccessDecisions bool `json:"access_decisions"` // Grants/denies access
ContentGeneration bool `json:"content_generation"` // Generates text/media
DataExport bool `json:"data_export"` // Exports data externally
}
// Hosting specifies where the AI runs
type Hosting struct {
Provider string `json:"provider,omitempty"` // e.g., "Azure", "AWS", "Hetzner", "On-Prem"
Region string `json:"region"` // "eu", "third_country", "on_prem"
DataResidency string `json:"data_residency,omitempty"` // Where data is stored
}
// ModelUsage specifies how the model is used
type ModelUsage struct {
RAG bool `json:"rag"` // Retrieval-Augmented Generation only
Finetune bool `json:"finetune"` // Fine-tuning with data
Training bool `json:"training"` // Full training with data
Inference bool `json:"inference"` // Inference only
}
// Retention specifies data retention
type Retention struct {
StorePrompts bool `json:"store_prompts"`
StoreResponses bool `json:"store_responses"`
RetentionDays int `json:"retention_days,omitempty"`
AnonymizeAfterUse bool `json:"anonymize_after_use"`
}
// ============================================================================
// Financial Regulations Structs (DORA, MaRisk, BAIT)
// ============================================================================
// FinancialEntityType represents the type of financial institution
type FinancialEntityType string
const (
FinancialEntityCreditInstitution FinancialEntityType = "CREDIT_INSTITUTION"
FinancialEntityPaymentServiceProvider FinancialEntityType = "PAYMENT_SERVICE_PROVIDER"
FinancialEntityEMoneyInstitution FinancialEntityType = "E_MONEY_INSTITUTION"
FinancialEntityInvestmentFirm FinancialEntityType = "INVESTMENT_FIRM"
FinancialEntityInsuranceCompany FinancialEntityType = "INSURANCE_COMPANY"
FinancialEntityCryptoAssetProvider FinancialEntityType = "CRYPTO_ASSET_PROVIDER"
FinancialEntityOther FinancialEntityType = "OTHER_FINANCIAL"
)
// SizeCategory represents the significance category of a financial institution
type SizeCategory string
const (
SizeCategorySignificant SizeCategory = "SIGNIFICANT"
SizeCategoryLessSignificant SizeCategory = "LESS_SIGNIFICANT"
SizeCategorySmall SizeCategory = "SMALL"
)
// ProviderLocation represents the location of an ICT service provider
type ProviderLocation string
const (
ProviderLocationEU ProviderLocation = "EU"
ProviderLocationEEA ProviderLocation = "EEA"
ProviderLocationAdequacyDecision ProviderLocation = "ADEQUACY_DECISION"
ProviderLocationThirdCountry ProviderLocation = "THIRD_COUNTRY"
)
// FinancialEntity describes the financial institution context
type FinancialEntity struct {
Type FinancialEntityType `json:"type"`
Regulated bool `json:"regulated"`
SizeCategory SizeCategory `json:"size_category"`
}
// ICTService describes ICT service characteristics for DORA compliance
type ICTService struct {
IsCritical bool `json:"is_critical"`
IsOutsourced bool `json:"is_outsourced"`
ProviderLocation ProviderLocation `json:"provider_location"`
ConcentrationRisk bool `json:"concentration_risk"`
}
// FinancialAIApplication describes financial-specific AI application characteristics
type FinancialAIApplication struct {
AffectsCustomerDecisions bool `json:"affects_customer_decisions"`
AlgorithmicTrading bool `json:"algorithmic_trading"`
RiskAssessment bool `json:"risk_assessment"`
AMLKYC bool `json:"aml_kyc"`
ModelValidationDone bool `json:"model_validation_done"`
}
// FinancialContext aggregates all financial regulation-specific information
type FinancialContext struct {
FinancialEntity FinancialEntity `json:"financial_entity"`
ICTService ICTService `json:"ict_service"`
AIApplication FinancialAIApplication `json:"ai_application"`
}
// ============================================================================
// Output Structs
// ============================================================================
// AssessmentResult represents the complete evaluation result
type AssessmentResult struct {
// Overall verdict
Feasibility Feasibility `json:"feasibility"`
RiskLevel RiskLevel `json:"risk_level"`
Complexity Complexity `json:"complexity"`
RiskScore int `json:"risk_score"` // 0-100
// Triggered rules
TriggeredRules []TriggeredRule `json:"triggered_rules"`
// Required controls/mitigations
RequiredControls []RequiredControl `json:"required_controls"`
// Recommended architecture patterns
RecommendedArchitecture []PatternRecommendation `json:"recommended_architecture"`
// Patterns that must NOT be used
ForbiddenPatterns []ForbiddenPattern `json:"forbidden_patterns"`
// Matching didactic examples
ExampleMatches []ExampleMatch `json:"example_matches"`
// Special flags
DSFARecommended bool `json:"dsfa_recommended"`
Art22Risk bool `json:"art22_risk"` // Art. 22 GDPR automated decision risk
TrainingAllowed TrainingAllowed `json:"training_allowed"`
// Summary for humans
Summary string `json:"summary"`
Recommendation string `json:"recommendation"`
AlternativeApproach string `json:"alternative_approach,omitempty"`
}
// TriggeredRule represents a rule that was triggered during evaluation
type TriggeredRule struct {
Code string `json:"code"` // e.g., "R-001"
Category string `json:"category"` // e.g., "A. Datenklassifikation"
Title string `json:"title"`
Description string `json:"description"`
Severity Severity `json:"severity"`
ScoreDelta int `json:"score_delta"`
GDPRRef string `json:"gdpr_ref,omitempty"` // e.g., "Art. 9 DSGVO"
Rationale string `json:"rationale"` // Why this rule triggered
}
// RequiredControl represents a control that must be implemented
type RequiredControl struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Severity Severity `json:"severity"`
Category string `json:"category"` // "technical" or "organizational"
GDPRRef string `json:"gdpr_ref,omitempty"`
}
// PatternRecommendation represents a recommended architecture pattern
type PatternRecommendation struct {
PatternID string `json:"pattern_id"` // e.g., "P-RAG-ONLY"
Title string `json:"title"`
Description string `json:"description"`
Rationale string `json:"rationale"`
Priority int `json:"priority"` // 1=highest
}
// ForbiddenPattern represents a pattern that must NOT be used
type ForbiddenPattern struct {
PatternID string `json:"pattern_id"`
Title string `json:"title"`
Description string `json:"description"`
Reason string `json:"reason"`
GDPRRef string `json:"gdpr_ref,omitempty"`
}
// ExampleMatch represents a matching didactic example
type ExampleMatch struct {
ExampleID string `json:"example_id"`
Title string `json:"title"`
Description string `json:"description"`
Similarity float64 `json:"similarity"` // 0.0 - 1.0
Outcome string `json:"outcome"` // What happened / recommendation
Lessons string `json:"lessons"` // Key takeaways
}
// ============================================================================
// Database Entity
// ============================================================================
// Assessment represents a stored assessment in the database
type Assessment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
NamespaceID *uuid.UUID `json:"namespace_id,omitempty"`
Title string `json:"title"`
PolicyVersion string `json:"policy_version"`
Status string `json:"status"` // "completed", "draft"
// Input
Intake UseCaseIntake `json:"intake"`
UseCaseTextStored bool `json:"use_case_text_stored"`
UseCaseTextHash string `json:"use_case_text_hash"`
// Results
Feasibility Feasibility `json:"feasibility"`
RiskLevel RiskLevel `json:"risk_level"`
Complexity Complexity `json:"complexity"`
RiskScore int `json:"risk_score"`
TriggeredRules []TriggeredRule `json:"triggered_rules"`
RequiredControls []RequiredControl `json:"required_controls"`
RecommendedArchitecture []PatternRecommendation `json:"recommended_architecture"`
ForbiddenPatterns []ForbiddenPattern `json:"forbidden_patterns"`
ExampleMatches []ExampleMatch `json:"example_matches"`
DSFARecommended bool `json:"dsfa_recommended"`
Art22Risk bool `json:"art22_risk"`
TrainingAllowed TrainingAllowed `json:"training_allowed"`
// LLM Explanation (optional)
ExplanationText *string `json:"explanation_text,omitempty"`
ExplanationGeneratedAt *time.Time `json:"explanation_generated_at,omitempty"`
ExplanationModel *string `json:"explanation_model,omitempty"`
// Domain
Domain Domain `json:"domain"`
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy uuid.UUID `json:"created_by"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// AssessRequest is the API request for creating an assessment
type AssessRequest struct {
Intake UseCaseIntake `json:"intake"`
}
// AssessResponse is the API response for an assessment
type AssessResponse struct {
Assessment Assessment `json:"assessment"`
Result AssessmentResult `json:"result"`
Escalation *Escalation `json:"escalation,omitempty"`
}
// ExplainRequest is the API request for generating an explanation
type ExplainRequest struct {
Language string `json:"language,omitempty"` // "de" or "en", default "de"
}
// ExplainResponse is the API response for an explanation
type ExplainResponse struct {
ExplanationText string `json:"explanation_text"`
GeneratedAt time.Time `json:"generated_at"`
Model string `json:"model"`
LegalContext *LegalContext `json:"legal_context,omitempty"`
}
// ExportFormat specifies the export format
type ExportFormat string
const (
ExportFormatJSON ExportFormat = "json"
ExportFormatMarkdown ExportFormat = "md"
)

View File

@@ -0,0 +1,762 @@
package ucca
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// ============================================================================
// NIS2 Module
// ============================================================================
//
// This module implements the NIS2 directive (EU 2022/2555) and the German
// implementation (BSIG-E - BSI-Gesetz Entwurf).
//
// NIS2 applies to:
// - Essential Entities (besonders wichtige Einrichtungen): Large enterprises in Annex I sectors
// - Important Entities (wichtige Einrichtungen): Medium enterprises in Annex I/II sectors
//
// Classification depends on:
// 1. Sector (Annex I = high criticality, Annex II = other critical)
// 2. Size (employees, revenue, balance sheet)
// 3. Special criteria (KRITIS, special services like DNS/TLD/Cloud)
//
// ============================================================================
// NIS2Module implements the RegulationModule interface for NIS2
type NIS2Module struct {
obligations []Obligation
controls []ObligationControl
incidentDeadlines []IncidentDeadline
decisionTree *DecisionTree
loaded bool
}
// NIS2 Sector Annexes
var (
// Annex I: Sectors of High Criticality
NIS2AnnexISectors = map[string]bool{
"energy": true, // Energie (Strom, Öl, Gas, Wasserstoff, Fernwärme)
"transport": true, // Verkehr (Luft, Schiene, Wasser, Straße)
"banking_financial": true, // Bankwesen
"financial_market": true, // Finanzmarktinfrastrukturen
"health": true, // Gesundheitswesen
"drinking_water": true, // Trinkwasser
"wastewater": true, // Abwasser
"digital_infrastructure": true, // Digitale Infrastruktur
"ict_service_mgmt": true, // IKT-Dienstverwaltung (B2B)
"public_administration": true, // Öffentliche Verwaltung
"space": true, // Weltraum
}
// Annex II: Other Critical Sectors
NIS2AnnexIISectors = map[string]bool{
"postal": true, // Post- und Kurierdienste
"waste": true, // Abfallbewirtschaftung
"chemicals": true, // Chemie
"food": true, // Lebensmittel
"manufacturing": true, // Verarbeitendes Gewerbe (wichtige Produkte)
"digital_providers": true, // Digitale Dienste (Marktplätze, Suchmaschinen, soziale Netze)
"research": true, // Forschung
}
// Special services that are always in scope (regardless of size)
NIS2SpecialServices = map[string]bool{
"dns": true, // DNS-Dienste
"tld": true, // TLD-Namenregister
"cloud": true, // Cloud-Computing-Dienste
"datacenter": true, // Rechenzentrumsdienste
"cdn": true, // Content-Delivery-Netze
"trust_service": true, // Vertrauensdienste
"public_network": true, // Öffentliche elektronische Kommunikationsnetze
"electronic_comms": true, // Elektronische Kommunikationsdienste
"msp": true, // Managed Service Provider
"mssp": true, // Managed Security Service Provider
}
)
// NewNIS2Module creates a new NIS2 module, loading obligations from YAML
func NewNIS2Module() (*NIS2Module, error) {
m := &NIS2Module{
obligations: []Obligation{},
controls: []ObligationControl{},
incidentDeadlines: []IncidentDeadline{},
}
// Try to load from YAML, fall back to hardcoded if not found
if err := m.loadFromYAML(); err != nil {
// Use hardcoded defaults
m.loadHardcodedObligations()
}
m.buildDecisionTree()
m.loaded = true
return m, nil
}
// ID returns the module identifier
func (m *NIS2Module) ID() string {
return "nis2"
}
// Name returns the human-readable name
func (m *NIS2Module) Name() string {
return "NIS2-Richtlinie / BSIG-E"
}
// Description returns a brief description
func (m *NIS2Module) Description() string {
return "EU-Richtlinie über Maßnahmen für ein hohes gemeinsames Cybersicherheitsniveau (NIS2) und deutsche Umsetzung (BSIG-E)"
}
// IsApplicable checks if NIS2 applies to the organization
func (m *NIS2Module) IsApplicable(facts *UnifiedFacts) bool {
classification := m.Classify(facts)
return classification != NIS2NotAffected
}
// GetClassification returns the NIS2 classification as string
func (m *NIS2Module) GetClassification(facts *UnifiedFacts) string {
return string(m.Classify(facts))
}
// Classify determines the NIS2 classification for an organization
func (m *NIS2Module) Classify(facts *UnifiedFacts) NIS2Classification {
// Check for special services (always in scope, regardless of size)
if m.hasSpecialService(facts) {
// Special services are typically essential entities
return NIS2EssentialEntity
}
// Check if in relevant sector
inAnnexI := NIS2AnnexISectors[facts.Sector.PrimarySector]
inAnnexII := NIS2AnnexIISectors[facts.Sector.PrimarySector]
if !inAnnexI && !inAnnexII {
// Not in a regulated sector
return NIS2NotAffected
}
// Check size thresholds
meetsSize := facts.Organization.MeetsNIS2SizeThreshold()
isLarge := facts.Organization.MeetsNIS2LargeThreshold()
if !meetsSize {
// Too small (< 50 employees AND < €10m revenue/balance)
// Exception: KRITIS operators are always in scope
if facts.Sector.IsKRITIS && facts.Sector.KRITISThresholdMet {
return NIS2EssentialEntity
}
return NIS2NotAffected
}
// Annex I sectors
if inAnnexI {
if isLarge {
// Large enterprise in Annex I = Essential Entity
return NIS2EssentialEntity
}
// Medium enterprise in Annex I = Important Entity
return NIS2ImportantEntity
}
// Annex II sectors
if inAnnexII {
if isLarge {
// Large enterprise in Annex II = Important Entity (not essential)
return NIS2ImportantEntity
}
// Medium enterprise in Annex II = Important Entity
return NIS2ImportantEntity
}
return NIS2NotAffected
}
// hasSpecialService checks if the organization provides special NIS2 services
func (m *NIS2Module) hasSpecialService(facts *UnifiedFacts) bool {
for _, service := range facts.Sector.SpecialServices {
if NIS2SpecialServices[service] {
return true
}
}
return false
}
// DeriveObligations derives all applicable NIS2 obligations
func (m *NIS2Module) DeriveObligations(facts *UnifiedFacts) []Obligation {
classification := m.Classify(facts)
if classification == NIS2NotAffected {
return []Obligation{}
}
var result []Obligation
for _, obl := range m.obligations {
if m.obligationApplies(obl, classification, facts) {
// Copy and customize obligation
customized := obl
customized.RegulationID = m.ID()
result = append(result, customized)
}
}
return result
}
// obligationApplies checks if a specific obligation applies
func (m *NIS2Module) obligationApplies(obl Obligation, classification NIS2Classification, facts *UnifiedFacts) bool {
// Check applies_when condition
switch obl.AppliesWhen {
case "classification == 'besonders_wichtige_einrichtung'":
return classification == NIS2EssentialEntity
case "classification == 'wichtige_einrichtung'":
return classification == NIS2ImportantEntity
case "classification in ['wichtige_einrichtung', 'besonders_wichtige_einrichtung']":
return classification == NIS2EssentialEntity || classification == NIS2ImportantEntity
case "classification != 'nicht_betroffen'":
return classification != NIS2NotAffected
case "":
// No condition = applies to all classified entities
return classification != NIS2NotAffected
default:
// Default: applies if not unaffected
return classification != NIS2NotAffected
}
}
// DeriveControls derives all applicable NIS2 controls
func (m *NIS2Module) DeriveControls(facts *UnifiedFacts) []ObligationControl {
classification := m.Classify(facts)
if classification == NIS2NotAffected {
return []ObligationControl{}
}
var result []ObligationControl
for _, ctrl := range m.controls {
ctrl.RegulationID = m.ID()
result = append(result, ctrl)
}
return result
}
// GetDecisionTree returns the NIS2 applicability decision tree
func (m *NIS2Module) GetDecisionTree() *DecisionTree {
return m.decisionTree
}
// GetIncidentDeadlines returns NIS2 incident reporting deadlines
func (m *NIS2Module) GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline {
classification := m.Classify(facts)
if classification == NIS2NotAffected {
return []IncidentDeadline{}
}
return m.incidentDeadlines
}
// ============================================================================
// YAML Loading
// ============================================================================
// NIS2ObligationsConfig is the YAML structure for NIS2 obligations
type NIS2ObligationsConfig struct {
Regulation string `yaml:"regulation"`
Name string `yaml:"name"`
Obligations []ObligationYAML `yaml:"obligations"`
Controls []ControlYAML `yaml:"controls"`
IncidentDeadlines []IncidentDeadlineYAML `yaml:"incident_deadlines"`
}
// ObligationYAML is the YAML structure for an obligation
type ObligationYAML struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Description string `yaml:"description"`
AppliesWhen string `yaml:"applies_when"`
LegalBasis []LegalRefYAML `yaml:"legal_basis"`
Category string `yaml:"category"`
Responsible string `yaml:"responsible"`
Deadline *DeadlineYAML `yaml:"deadline,omitempty"`
Sanctions *SanctionYAML `yaml:"sanctions,omitempty"`
Evidence []string `yaml:"evidence,omitempty"`
Priority string `yaml:"priority"`
ISO27001 []string `yaml:"iso27001_mapping,omitempty"`
HowTo string `yaml:"how_to_implement,omitempty"`
}
type LegalRefYAML struct {
Norm string `yaml:"norm"`
Article string `yaml:"article,omitempty"`
}
type DeadlineYAML struct {
Type string `yaml:"type"`
Date string `yaml:"date,omitempty"`
Duration string `yaml:"duration,omitempty"`
}
type SanctionYAML struct {
MaxFine string `yaml:"max_fine,omitempty"`
PersonalLiability bool `yaml:"personal_liability,omitempty"`
}
type ControlYAML struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Category string `yaml:"category"`
WhatToDo string `yaml:"what_to_do"`
ISO27001 []string `yaml:"iso27001_mapping,omitempty"`
Priority string `yaml:"priority"`
}
type IncidentDeadlineYAML struct {
Phase string `yaml:"phase"`
Deadline string `yaml:"deadline"`
Content string `yaml:"content"`
Recipient string `yaml:"recipient"`
LegalBasis []LegalRefYAML `yaml:"legal_basis"`
}
func (m *NIS2Module) loadFromYAML() error {
// Search paths for YAML file
searchPaths := []string{
"policies/obligations/nis2_obligations.yaml",
filepath.Join(".", "policies", "obligations", "nis2_obligations.yaml"),
filepath.Join("..", "policies", "obligations", "nis2_obligations.yaml"),
filepath.Join("..", "..", "policies", "obligations", "nis2_obligations.yaml"),
"/app/policies/obligations/nis2_obligations.yaml",
}
var data []byte
var err error
for _, path := range searchPaths {
data, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("NIS2 obligations YAML not found: %w", err)
}
var config NIS2ObligationsConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return fmt.Errorf("failed to parse NIS2 YAML: %w", err)
}
// Convert YAML to internal structures
m.convertObligations(config.Obligations)
m.convertControls(config.Controls)
m.convertIncidentDeadlines(config.IncidentDeadlines)
return nil
}
func (m *NIS2Module) convertObligations(yamlObls []ObligationYAML) {
for _, y := range yamlObls {
obl := Obligation{
ID: y.ID,
RegulationID: "nis2",
Title: y.Title,
Description: y.Description,
AppliesWhen: y.AppliesWhen,
Category: ObligationCategory(y.Category),
Responsible: ResponsibleRole(y.Responsible),
Priority: ObligationPriority(y.Priority),
ISO27001Mapping: y.ISO27001,
HowToImplement: y.HowTo,
}
// Convert legal basis
for _, lb := range y.LegalBasis {
obl.LegalBasis = append(obl.LegalBasis, LegalReference{
Norm: lb.Norm,
Article: lb.Article,
})
}
// Convert deadline
if y.Deadline != nil {
obl.Deadline = &Deadline{
Type: DeadlineType(y.Deadline.Type),
Duration: y.Deadline.Duration,
}
if y.Deadline.Date != "" {
if t, err := time.Parse("2006-01-02", y.Deadline.Date); err == nil {
obl.Deadline.Date = &t
}
}
}
// Convert sanctions
if y.Sanctions != nil {
obl.Sanctions = &SanctionInfo{
MaxFine: y.Sanctions.MaxFine,
PersonalLiability: y.Sanctions.PersonalLiability,
}
}
// Convert evidence
for _, e := range y.Evidence {
obl.Evidence = append(obl.Evidence, EvidenceItem{Name: e, Required: true})
}
m.obligations = append(m.obligations, obl)
}
}
func (m *NIS2Module) convertControls(yamlCtrls []ControlYAML) {
for _, y := range yamlCtrls {
ctrl := ObligationControl{
ID: y.ID,
RegulationID: "nis2",
Name: y.Name,
Description: y.Description,
Category: y.Category,
WhatToDo: y.WhatToDo,
ISO27001Mapping: y.ISO27001,
Priority: ObligationPriority(y.Priority),
}
m.controls = append(m.controls, ctrl)
}
}
func (m *NIS2Module) convertIncidentDeadlines(yamlDeadlines []IncidentDeadlineYAML) {
for _, y := range yamlDeadlines {
deadline := IncidentDeadline{
RegulationID: "nis2",
Phase: y.Phase,
Deadline: y.Deadline,
Content: y.Content,
Recipient: y.Recipient,
}
for _, lb := range y.LegalBasis {
deadline.LegalBasis = append(deadline.LegalBasis, LegalReference{
Norm: lb.Norm,
Article: lb.Article,
})
}
m.incidentDeadlines = append(m.incidentDeadlines, deadline)
}
}
// ============================================================================
// Hardcoded Fallback
// ============================================================================
func (m *NIS2Module) loadHardcodedObligations() {
// BSI Registration deadline
bsiDeadline := time.Date(2025, 1, 17, 0, 0, 0, 0, time.UTC)
m.obligations = []Obligation{
{
ID: "NIS2-OBL-001",
RegulationID: "nis2",
Title: "BSI-Registrierung",
Description: "Registrierung beim BSI über das Meldeportal. Anzugeben sind: Kontaktdaten, IP-Bereiche, verantwortliche Ansprechpartner.",
LegalBasis: []LegalReference{{Norm: "§ 33 BSIG-E", Article: "Registrierungspflicht"}},
Category: CategoryMeldepflicht,
Responsible: RoleManagement,
Deadline: &Deadline{Type: DeadlineAbsolute, Date: &bsiDeadline},
Sanctions: &SanctionInfo{MaxFine: "500.000 EUR", PersonalLiability: false},
Evidence: []EvidenceItem{{Name: "Registrierungsbestätigung BSI", Required: true}, {Name: "Dokumentierte Ansprechpartner", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "classification in ['wichtige_einrichtung', 'besonders_wichtige_einrichtung']",
},
{
ID: "NIS2-OBL-002",
RegulationID: "nis2",
Title: "Risikomanagement-Maßnahmen implementieren",
Description: "Umsetzung angemessener technischer, operativer und organisatorischer Maßnahmen zur Beherrschung der Risiken für die Sicherheit der Netz- und Informationssysteme.",
LegalBasis: []LegalReference{{Norm: "Art. 21 NIS2"}, {Norm: "§ 30 BSIG-E"}},
Category: CategoryGovernance,
Responsible: RoleCISO,
Deadline: &Deadline{Type: DeadlineRelative, Duration: "18 Monate nach Inkrafttreten"},
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz", PersonalLiability: true},
Evidence: []EvidenceItem{{Name: "ISMS-Dokumentation", Required: true}, {Name: "Risikoanalyse", Required: true}, {Name: "Maßnahmenkatalog", Required: true}},
Priority: PriorityHigh,
ISO27001Mapping: []string{"A.5", "A.6", "A.8"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-003",
RegulationID: "nis2",
Title: "Geschäftsführungs-Verantwortung",
Description: "Die Geschäftsleitung muss die Risikomanagementmaßnahmen genehmigen, deren Umsetzung überwachen und kann für Verstöße persönlich haftbar gemacht werden.",
LegalBasis: []LegalReference{{Norm: "Art. 20 NIS2"}, {Norm: "§ 38 BSIG-E"}},
Category: CategoryGovernance,
Responsible: RoleManagement,
Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz", PersonalLiability: true},
Evidence: []EvidenceItem{{Name: "Vorstandsbeschluss zur Cybersicherheit", Required: true}, {Name: "Dokumentierte Genehmigung der Maßnahmen", Required: true}},
Priority: PriorityCritical,
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-004",
RegulationID: "nis2",
Title: "Cybersicherheits-Schulung der Geschäftsführung",
Description: "Mitglieder der Leitungsorgane müssen an Schulungen teilnehmen, um ausreichende Kenntnisse und Fähigkeiten zur Erkennung und Bewertung von Risiken zu erlangen.",
LegalBasis: []LegalReference{{Norm: "Art. 20 Abs. 2 NIS2"}, {Norm: "§ 38 Abs. 3 BSIG-E"}},
Category: CategoryTraining,
Responsible: RoleManagement,
Deadline: &Deadline{Type: DeadlineRecurring, Interval: "jährlich"},
Evidence: []EvidenceItem{{Name: "Schulungsnachweise der Geschäftsführung", Required: true}, {Name: "Schulungsplan", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-005",
RegulationID: "nis2",
Title: "Incident-Response-Prozess etablieren",
Description: "Etablierung eines Prozesses zur Erkennung, Analyse und Meldung von Sicherheitsvorfällen gemäß den gesetzlichen Meldefristen.",
LegalBasis: []LegalReference{{Norm: "Art. 23 NIS2"}, {Norm: "§ 32 BSIG-E"}},
Category: CategoryTechnical,
Responsible: RoleCISO,
Evidence: []EvidenceItem{{Name: "Incident-Response-Plan", Required: true}, {Name: "Meldeprozess-Dokumentation", Required: true}, {Name: "Kontaktdaten BSI", Required: true}},
Priority: PriorityCritical,
ISO27001Mapping: []string{"A.16"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-006",
RegulationID: "nis2",
Title: "Business Continuity Management",
Description: "Maßnahmen zur Aufrechterhaltung des Betriebs, Backup-Management, Notfallwiederherstellung und Krisenmanagement.",
LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. c NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 3 BSIG-E"}},
Category: CategoryTechnical,
Responsible: RoleCISO,
Evidence: []EvidenceItem{{Name: "BCM-Dokumentation", Required: true}, {Name: "Backup-Konzept", Required: true}, {Name: "Disaster-Recovery-Plan", Required: true}, {Name: "Testprotokolle", Required: true}},
Priority: PriorityHigh,
ISO27001Mapping: []string{"A.17"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-007",
RegulationID: "nis2",
Title: "Lieferketten-Sicherheit",
Description: "Sicherheit in der Lieferkette, einschließlich sicherheitsbezogener Aspekte der Beziehungen zwischen Einrichtung und direkten Anbietern oder Diensteanbietern.",
LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. d NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 4 BSIG-E"}},
Category: CategoryOrganizational,
Responsible: RoleCISO,
Evidence: []EvidenceItem{{Name: "Lieferanten-Risikobewertung", Required: true}, {Name: "Sicherheitsanforderungen in Verträgen", Required: true}},
Priority: PriorityMedium,
ISO27001Mapping: []string{"A.15"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-008",
RegulationID: "nis2",
Title: "Schwachstellenmanagement",
Description: "Umgang mit Schwachstellen und deren Offenlegung, Maßnahmen zur Erkennung und Behebung von Schwachstellen.",
LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. e NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 5 BSIG-E"}},
Category: CategoryTechnical,
Responsible: RoleCISO,
Evidence: []EvidenceItem{{Name: "Schwachstellen-Management-Prozess", Required: true}, {Name: "Patch-Management-Richtlinie", Required: true}, {Name: "Vulnerability-Scan-Berichte", Required: true}},
Priority: PriorityHigh,
ISO27001Mapping: []string{"A.12.6"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-009",
RegulationID: "nis2",
Title: "Zugangs- und Identitätsmanagement",
Description: "Konzepte für die Zugangskontrolle und das Management von Anlagen sowie Verwendung von MFA und kontinuierlicher Authentifizierung.",
LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. i NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 9 BSIG-E"}},
Category: CategoryTechnical,
Responsible: RoleITLeitung,
Evidence: []EvidenceItem{{Name: "Zugangskontroll-Richtlinie", Required: true}, {Name: "MFA-Implementierungsnachweis", Required: true}, {Name: "Identity-Management-Dokumentation", Required: true}},
Priority: PriorityHigh,
ISO27001Mapping: []string{"A.9"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-010",
RegulationID: "nis2",
Title: "Kryptographie und Verschlüsselung",
Description: "Konzepte und Verfahren für den Einsatz von Kryptographie und gegebenenfalls Verschlüsselung.",
LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. h NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 8 BSIG-E"}},
Category: CategoryTechnical,
Responsible: RoleCISO,
Evidence: []EvidenceItem{{Name: "Kryptographie-Richtlinie", Required: true}, {Name: "Verschlüsselungskonzept", Required: true}, {Name: "Key-Management-Dokumentation", Required: true}},
Priority: PriorityMedium,
ISO27001Mapping: []string{"A.10"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-011",
RegulationID: "nis2",
Title: "Personalsicherheit",
Description: "Sicherheit des Personals, Konzepte für die Zugriffskontrolle und das Management von Anlagen.",
LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. j NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 10 BSIG-E"}},
Category: CategoryOrganizational,
Responsible: RoleManagement,
Evidence: []EvidenceItem{{Name: "Personalsicherheits-Richtlinie", Required: true}, {Name: "Schulungskonzept", Required: true}},
Priority: PriorityMedium,
ISO27001Mapping: []string{"A.7"},
AppliesWhen: "classification != 'nicht_betroffen'",
},
{
ID: "NIS2-OBL-012",
RegulationID: "nis2",
Title: "Regelmäßige Audits (besonders wichtige Einrichtungen)",
Description: "Besonders wichtige Einrichtungen unterliegen regelmäßigen Sicherheitsüberprüfungen durch das BSI.",
LegalBasis: []LegalReference{{Norm: "Art. 32 NIS2"}, {Norm: "§ 39 BSIG-E"}},
Category: CategoryAudit,
Responsible: RoleCISO,
Deadline: &Deadline{Type: DeadlineRecurring, Interval: "alle 2 Jahre"},
Evidence: []EvidenceItem{{Name: "Audit-Berichte", Required: true}, {Name: "Maßnahmenplan aus Audits", Required: true}},
Priority: PriorityHigh,
AppliesWhen: "classification == 'besonders_wichtige_einrichtung'",
},
}
// Hardcoded controls
m.controls = []ObligationControl{
{
ID: "NIS2-CTRL-001",
RegulationID: "nis2",
Name: "ISMS implementieren",
Description: "Implementierung eines Informationssicherheits-Managementsystems",
Category: "Governance",
WhatToDo: "Aufbau eines ISMS nach ISO 27001 oder BSI IT-Grundschutz",
ISO27001Mapping: []string{"4", "5", "6", "7"},
Priority: PriorityHigh,
},
{
ID: "NIS2-CTRL-002",
RegulationID: "nis2",
Name: "Netzwerksegmentierung",
Description: "Segmentierung kritischer Netzwerkbereiche",
Category: "Technisch",
WhatToDo: "Implementierung von VLANs, Firewalls und Mikrosegmentierung für kritische Systeme",
ISO27001Mapping: []string{"A.13.1"},
Priority: PriorityHigh,
},
{
ID: "NIS2-CTRL-003",
RegulationID: "nis2",
Name: "Security Monitoring",
Description: "Kontinuierliche Überwachung der IT-Sicherheit",
Category: "Technisch",
WhatToDo: "Implementierung von SIEM, Log-Management und Anomalie-Erkennung",
ISO27001Mapping: []string{"A.12.4"},
Priority: PriorityHigh,
},
{
ID: "NIS2-CTRL-004",
RegulationID: "nis2",
Name: "Awareness-Programm",
Description: "Regelmäßige Sicherheitsschulungen für alle Mitarbeiter",
Category: "Organisatorisch",
WhatToDo: "Durchführung von Phishing-Simulationen, E-Learning und Präsenzschulungen",
ISO27001Mapping: []string{"A.7.2.2"},
Priority: PriorityMedium,
},
}
// Hardcoded incident deadlines
m.incidentDeadlines = []IncidentDeadline{
{
RegulationID: "nis2",
Phase: "Frühwarnung",
Deadline: "24 Stunden",
Content: "Unverzügliche Meldung erheblicher Sicherheitsvorfälle. Angabe ob böswilliger Angriff vermutet und ob grenzüberschreitende Auswirkungen möglich.",
Recipient: "BSI",
LegalBasis: []LegalReference{{Norm: "§ 32 Abs. 1 BSIG-E"}},
},
{
RegulationID: "nis2",
Phase: "Vorfallmeldung",
Deadline: "72 Stunden",
Content: "Aktualisierung der Frühwarnung. Erste Bewertung des Vorfalls, Schweregrad, Auswirkungen, Kompromittierungsindikatoren (IoCs).",
Recipient: "BSI",
LegalBasis: []LegalReference{{Norm: "§ 32 Abs. 2 BSIG-E"}},
},
{
RegulationID: "nis2",
Phase: "Abschlussbericht",
Deadline: "1 Monat",
Content: "Ausführliche Beschreibung des Vorfalls, Ursachenanalyse (Root Cause), ergriffene Abhilfemaßnahmen, grenzüberschreitende Auswirkungen.",
Recipient: "BSI",
LegalBasis: []LegalReference{{Norm: "§ 32 Abs. 3 BSIG-E"}},
},
}
}
// ============================================================================
// Decision Tree
// ============================================================================
func (m *NIS2Module) buildDecisionTree() {
m.decisionTree = &DecisionTree{
ID: "nis2_applicability",
Name: "NIS2 Anwendbarkeits-Entscheidungsbaum",
RootNode: &DecisionNode{
ID: "root",
Question: "Erbringt Ihr Unternehmen spezielle digitale Dienste (DNS, TLD, Cloud, Rechenzentrum, CDN, MSP, MSSP, Vertrauensdienste)?",
YesNode: &DecisionNode{
ID: "special_services",
Result: string(NIS2EssentialEntity),
Explanation: "Anbieter spezieller digitaler Dienste sind unabhängig von der Größe als besonders wichtige Einrichtungen einzustufen.",
},
NoNode: &DecisionNode{
ID: "sector_check",
Question: "Ist Ihr Unternehmen in einem der NIS2-Sektoren tätig (Energie, Verkehr, Gesundheit, Digitale Infrastruktur, Öffentliche Verwaltung, Finanzwesen, etc.)?",
YesNode: &DecisionNode{
ID: "size_check",
Question: "Hat Ihr Unternehmen mindestens 50 Mitarbeiter ODER mindestens 10 Mio. EUR Jahresumsatz UND Bilanzsumme?",
YesNode: &DecisionNode{
ID: "annex_check",
Question: "Ist Ihr Sektor in Anhang I der NIS2 (hohe Kritikalität: Energie, Verkehr, Gesundheit, Trinkwasser, Digitale Infrastruktur, Bankwesen, Öffentliche Verwaltung, Weltraum)?",
YesNode: &DecisionNode{
ID: "large_check_annex1",
Question: "Hat Ihr Unternehmen mindestens 250 Mitarbeiter ODER mindestens 50 Mio. EUR Jahresumsatz?",
YesNode: &DecisionNode{
ID: "essential_annex1",
Result: string(NIS2EssentialEntity),
Explanation: "Großes Unternehmen in Anhang I Sektor = Besonders wichtige Einrichtung",
},
NoNode: &DecisionNode{
ID: "important_annex1",
Result: string(NIS2ImportantEntity),
Explanation: "Mittleres Unternehmen in Anhang I Sektor = Wichtige Einrichtung",
},
},
NoNode: &DecisionNode{
ID: "important_annex2",
Result: string(NIS2ImportantEntity),
Explanation: "Unternehmen in Anhang II Sektor = Wichtige Einrichtung",
},
},
NoNode: &DecisionNode{
ID: "kritis_check",
Question: "Ist Ihr Unternehmen als KRITIS-Betreiber eingestuft?",
YesNode: &DecisionNode{
ID: "kritis_essential",
Result: string(NIS2EssentialEntity),
Explanation: "KRITIS-Betreiber sind unabhängig von der Größe als besonders wichtige Einrichtungen einzustufen.",
},
NoNode: &DecisionNode{
ID: "too_small",
Result: string(NIS2NotAffected),
Explanation: "Unternehmen unterhalb der Größenschwelle ohne KRITIS-Status sind nicht von NIS2 betroffen.",
},
},
},
NoNode: &DecisionNode{
ID: "not_in_sector",
Result: string(NIS2NotAffected),
Explanation: "Unternehmen außerhalb der NIS2-Sektoren sind nicht betroffen.",
},
},
},
}
}

View File

@@ -0,0 +1,556 @@
package ucca
import (
"testing"
)
func TestNIS2Module_Classification(t *testing.T) {
module, err := NewNIS2Module()
if err != nil {
t.Fatalf("Failed to create NIS2 module: %v", err)
}
tests := []struct {
name string
facts *UnifiedFacts
expectedClass NIS2Classification
expectedApply bool
}{
{
name: "Large energy company - Essential Entity",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 500,
AnnualRevenue: 100_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
{
name: "Medium energy company - Important Entity",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 100,
AnnualRevenue: 20_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
},
expectedClass: NIS2ImportantEntity,
expectedApply: true,
},
{
name: "Small energy company - Not Affected",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 20,
AnnualRevenue: 5_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
},
expectedClass: NIS2NotAffected,
expectedApply: false,
},
{
name: "Large healthcare company - Essential Entity",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 300,
AnnualRevenue: 80_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "health",
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
{
name: "Medium manufacturing company (Annex II) - Important Entity",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 150,
AnnualRevenue: 30_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "manufacturing",
},
},
expectedClass: NIS2ImportantEntity,
expectedApply: true,
},
{
name: "Large manufacturing company (Annex II) - Important Entity (not Essential)",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 500,
AnnualRevenue: 100_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "manufacturing",
},
},
expectedClass: NIS2ImportantEntity,
expectedApply: true,
},
{
name: "Retail company - Not Affected (not in NIS2 sectors)",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 500,
AnnualRevenue: 100_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "retail",
},
},
expectedClass: NIS2NotAffected,
expectedApply: false,
},
{
name: "Small KRITIS operator - Essential Entity (exception)",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 30,
AnnualRevenue: 8_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "energy",
IsKRITIS: true,
KRITISThresholdMet: true,
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
{
name: "Cloud provider (special service) - Essential Entity regardless of size",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 20,
AnnualRevenue: 5_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "it_services",
SpecialServices: []string{"cloud"},
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
{
name: "DNS provider (special service) - Essential Entity regardless of size",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 10,
AnnualRevenue: 2_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "digital_infrastructure",
SpecialServices: []string{"dns"},
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
{
name: "MSP provider - Essential Entity",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 15,
AnnualRevenue: 3_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "ict_service_mgmt",
SpecialServices: []string{"msp"},
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
{
name: "Group company (counts group size) - Essential Entity",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 50,
AnnualRevenue: 15_000_000,
IsPartOfGroup: true,
GroupEmployees: 500,
GroupRevenue: 200_000_000,
Country: "DE",
EUMember: true,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
},
expectedClass: NIS2EssentialEntity,
expectedApply: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
classification := module.Classify(tt.facts)
if classification != tt.expectedClass {
t.Errorf("Classify() = %v, want %v", classification, tt.expectedClass)
}
isApplicable := module.IsApplicable(tt.facts)
if isApplicable != tt.expectedApply {
t.Errorf("IsApplicable() = %v, want %v", isApplicable, tt.expectedApply)
}
})
}
}
func TestNIS2Module_DeriveObligations(t *testing.T) {
module, err := NewNIS2Module()
if err != nil {
t.Fatalf("Failed to create NIS2 module: %v", err)
}
tests := []struct {
name string
facts *UnifiedFacts
minObligations int
expectedContains []string // Obligation IDs that should be present
expectedMissing []string // Obligation IDs that should NOT be present
}{
{
name: "Essential Entity should have all obligations including audits",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 500,
AnnualRevenue: 100_000_000,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
},
minObligations: 10,
expectedContains: []string{"NIS2-OBL-001", "NIS2-OBL-002", "NIS2-OBL-012"}, // BSI registration, risk mgmt, audits
},
{
name: "Important Entity should have obligations but NOT audit requirement",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 100,
AnnualRevenue: 20_000_000,
},
Sector: SectorFacts{
PrimarySector: "manufacturing",
},
},
minObligations: 8,
expectedContains: []string{"NIS2-OBL-001", "NIS2-OBL-002"},
expectedMissing: []string{"NIS2-OBL-012"}, // Audits only for essential entities
},
{
name: "Not affected entity should have no obligations",
facts: &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 20,
AnnualRevenue: 5_000_000,
},
Sector: SectorFacts{
PrimarySector: "retail",
},
},
minObligations: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obligations := module.DeriveObligations(tt.facts)
if len(obligations) < tt.minObligations {
t.Errorf("DeriveObligations() returned %d obligations, want at least %d", len(obligations), tt.minObligations)
}
// Check expected contains
for _, expectedID := range tt.expectedContains {
found := false
for _, obl := range obligations {
if obl.ID == expectedID {
found = true
break
}
}
if !found {
t.Errorf("Expected obligation %s not found in results", expectedID)
}
}
// Check expected missing
for _, missingID := range tt.expectedMissing {
for _, obl := range obligations {
if obl.ID == missingID {
t.Errorf("Obligation %s should NOT be present for this classification", missingID)
}
}
}
})
}
}
func TestNIS2Module_IncidentDeadlines(t *testing.T) {
module, err := NewNIS2Module()
if err != nil {
t.Fatalf("Failed to create NIS2 module: %v", err)
}
// Test for affected entity
affectedFacts := &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 100,
AnnualRevenue: 20_000_000,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
}
deadlines := module.GetIncidentDeadlines(affectedFacts)
if len(deadlines) != 3 {
t.Errorf("Expected 3 incident deadlines, got %d", len(deadlines))
}
// Check phases
expectedPhases := map[string]string{
"Frühwarnung": "24 Stunden",
"Vorfallmeldung": "72 Stunden",
"Abschlussbericht": "1 Monat",
}
for _, deadline := range deadlines {
if expected, ok := expectedPhases[deadline.Phase]; ok {
if deadline.Deadline != expected {
t.Errorf("Phase %s: expected deadline %s, got %s", deadline.Phase, expected, deadline.Deadline)
}
}
}
// Test for not affected entity
notAffectedFacts := &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 20,
AnnualRevenue: 5_000_000,
},
Sector: SectorFacts{
PrimarySector: "retail",
},
}
deadlines = module.GetIncidentDeadlines(notAffectedFacts)
if len(deadlines) != 0 {
t.Errorf("Expected 0 incident deadlines for not affected entity, got %d", len(deadlines))
}
}
func TestNIS2Module_DecisionTree(t *testing.T) {
module, err := NewNIS2Module()
if err != nil {
t.Fatalf("Failed to create NIS2 module: %v", err)
}
tree := module.GetDecisionTree()
if tree == nil {
t.Fatal("Expected decision tree, got nil")
}
if tree.ID != "nis2_applicability" {
t.Errorf("Expected tree ID 'nis2_applicability', got %s", tree.ID)
}
if tree.RootNode == nil {
t.Fatal("Expected root node, got nil")
}
// Verify structure
if tree.RootNode.YesNode == nil || tree.RootNode.NoNode == nil {
t.Error("Root node should have both yes and no branches")
}
}
func TestNIS2Module_DeriveControls(t *testing.T) {
module, err := NewNIS2Module()
if err != nil {
t.Fatalf("Failed to create NIS2 module: %v", err)
}
// Test for affected entity
affectedFacts := &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 100,
AnnualRevenue: 20_000_000,
},
Sector: SectorFacts{
PrimarySector: "energy",
},
}
controls := module.DeriveControls(affectedFacts)
if len(controls) == 0 {
t.Error("Expected controls for affected entity, got none")
}
// Check that controls have regulation ID set
for _, ctrl := range controls {
if ctrl.RegulationID != "nis2" {
t.Errorf("Control %s has wrong regulation ID: %s", ctrl.ID, ctrl.RegulationID)
}
}
// Test for not affected entity
notAffectedFacts := &UnifiedFacts{
Organization: OrganizationFacts{
EmployeeCount: 20,
AnnualRevenue: 5_000_000,
},
Sector: SectorFacts{
PrimarySector: "retail",
},
}
controls = module.DeriveControls(notAffectedFacts)
if len(controls) != 0 {
t.Errorf("Expected 0 controls for not affected entity, got %d", len(controls))
}
}
func TestOrganizationFacts_SizeCalculations(t *testing.T) {
tests := []struct {
name string
org OrganizationFacts
expectedSize string
expectedSME bool
expectedNIS2 bool
expectedLarge bool
}{
{
name: "Micro enterprise",
org: OrganizationFacts{
EmployeeCount: 5,
AnnualRevenue: 1_000_000,
},
expectedSize: "micro",
expectedSME: true,
expectedNIS2: false,
expectedLarge: false,
},
{
name: "Small enterprise",
org: OrganizationFacts{
EmployeeCount: 30,
AnnualRevenue: 8_000_000,
},
expectedSize: "small",
expectedSME: true,
expectedNIS2: false,
expectedLarge: false,
},
{
name: "Medium enterprise (by employees)",
org: OrganizationFacts{
EmployeeCount: 100,
AnnualRevenue: 20_000_000,
},
expectedSize: "medium",
expectedSME: true,
expectedNIS2: true,
expectedLarge: false,
},
{
name: "Medium enterprise (by revenue/balance)",
org: OrganizationFacts{
EmployeeCount: 40,
AnnualRevenue: 15_000_000,
BalanceSheetTotal: 15_000_000,
},
expectedSize: "medium", // < 50 employees BUT revenue AND balance > €10m → not small
expectedSME: true,
expectedNIS2: true, // >= €10m revenue AND balance
expectedLarge: false,
},
{
name: "Large enterprise",
org: OrganizationFacts{
EmployeeCount: 500,
AnnualRevenue: 100_000_000,
},
expectedSize: "large",
expectedSME: false,
expectedNIS2: true,
expectedLarge: true,
},
{
name: "Group company (uses group figures)",
org: OrganizationFacts{
EmployeeCount: 50,
AnnualRevenue: 15_000_000,
IsPartOfGroup: true,
GroupEmployees: 500,
GroupRevenue: 200_000_000,
},
expectedSize: "large",
expectedSME: false,
expectedNIS2: true,
expectedLarge: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
size := tt.org.CalculateSizeCategory()
if size != tt.expectedSize {
t.Errorf("CalculateSizeCategory() = %v, want %v", size, tt.expectedSize)
}
isSME := tt.org.IsSME()
if isSME != tt.expectedSME {
t.Errorf("IsSME() = %v, want %v", isSME, tt.expectedSME)
}
meetsNIS2 := tt.org.MeetsNIS2SizeThreshold()
if meetsNIS2 != tt.expectedNIS2 {
t.Errorf("MeetsNIS2SizeThreshold() = %v, want %v", meetsNIS2, tt.expectedNIS2)
}
isLarge := tt.org.MeetsNIS2LargeThreshold()
if isLarge != tt.expectedLarge {
t.Errorf("MeetsNIS2LargeThreshold() = %v, want %v", isLarge, tt.expectedLarge)
}
})
}
}

View File

@@ -0,0 +1,381 @@
package ucca
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Generic Obligations Framework
// ============================================================================
//
// This framework provides a regulation-agnostic way to derive and manage
// compliance obligations. Each regulation (DSGVO, NIS2, AI Act, etc.) is
// implemented as a separate module that conforms to the RegulationModule
// interface.
//
// Key principles:
// - Deterministic: No LLM involvement in obligation derivation
// - Transparent: Obligations are traceable to legal basis
// - Composable: Regulations can be combined
// - Auditable: Full traceability for compliance evidence
//
// ============================================================================
// ============================================================================
// Enums and Constants
// ============================================================================
// ObligationPriority represents the urgency of an obligation
type ObligationPriority string
const (
PriorityCritical ObligationPriority = "critical"
PriorityHigh ObligationPriority = "high"
PriorityMedium ObligationPriority = "medium"
PriorityLow ObligationPriority = "low"
)
// ObligationCategory represents the type of obligation
type ObligationCategory string
const (
CategoryMeldepflicht ObligationCategory = "Meldepflicht"
CategoryGovernance ObligationCategory = "Governance"
CategoryTechnical ObligationCategory = "Technisch"
CategoryOrganizational ObligationCategory = "Organisatorisch"
CategoryDocumentation ObligationCategory = "Dokumentation"
CategoryTraining ObligationCategory = "Schulung"
CategoryAudit ObligationCategory = "Audit"
CategoryCompliance ObligationCategory = "Compliance"
)
// ResponsibleRole represents who is responsible for an obligation
type ResponsibleRole string
const (
RoleManagement ResponsibleRole = "Geschäftsführung"
RoleDSB ResponsibleRole = "Datenschutzbeauftragter"
RoleCISO ResponsibleRole = "CISO"
RoleITLeitung ResponsibleRole = "IT-Leitung"
RoleCompliance ResponsibleRole = "Compliance-Officer"
RoleAIBeauftragter ResponsibleRole = "KI-Beauftragter"
RoleKIVerantwortlicher ResponsibleRole = "KI-Verantwortlicher"
RoleRiskManager ResponsibleRole = "Risikomanager"
RoleFachbereich ResponsibleRole = "Fachbereichsleitung"
)
// DeadlineType represents the type of deadline
type DeadlineType string
const (
DeadlineAbsolute DeadlineType = "absolute"
DeadlineRelative DeadlineType = "relative"
DeadlineRecurring DeadlineType = "recurring"
DeadlineOnEvent DeadlineType = "on_event"
)
// NIS2Classification represents NIS2 entity classification
type NIS2Classification string
const (
NIS2NotAffected NIS2Classification = "nicht_betroffen"
NIS2ImportantEntity NIS2Classification = "wichtige_einrichtung"
NIS2EssentialEntity NIS2Classification = "besonders_wichtige_einrichtung"
)
// ============================================================================
// Core Interfaces
// ============================================================================
// RegulationModule is the interface that all regulation modules must implement
type RegulationModule interface {
// ID returns the unique identifier for this regulation (e.g., "nis2", "dsgvo")
ID() string
// Name returns the human-readable name (e.g., "NIS2-Richtlinie")
Name() string
// Description returns a brief description of the regulation
Description() string
// IsApplicable checks if this regulation applies to the given organization
IsApplicable(facts *UnifiedFacts) bool
// DeriveObligations derives all obligations based on the facts
DeriveObligations(facts *UnifiedFacts) []Obligation
// DeriveControls derives required controls based on the facts
DeriveControls(facts *UnifiedFacts) []ObligationControl
// GetDecisionTree returns the decision tree for this regulation (optional)
GetDecisionTree() *DecisionTree
// GetIncidentDeadlines returns incident reporting deadlines (optional)
GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline
// GetClassification returns the specific classification within this regulation
GetClassification(facts *UnifiedFacts) string
}
// ============================================================================
// Core Data Structures
// ============================================================================
// LegalReference represents a reference to a specific legal provision
type LegalReference struct {
Norm string `json:"norm" yaml:"norm"` // e.g., "Art. 28 DSGVO", "§ 33 BSIG-E"
Article string `json:"article,omitempty" yaml:"article,omitempty"` // Article/paragraph number
Title string `json:"title,omitempty" yaml:"title,omitempty"` // Title of the provision
Description string `json:"description,omitempty" yaml:"description,omitempty"` // Brief description
URL string `json:"url,omitempty" yaml:"url,omitempty"` // Link to full text
}
// Deadline represents when an obligation must be fulfilled
type Deadline struct {
Type DeadlineType `json:"type" yaml:"type"` // absolute, relative, recurring, on_event
Date *time.Time `json:"date,omitempty" yaml:"date,omitempty"` // For absolute deadlines
Duration string `json:"duration,omitempty" yaml:"duration,omitempty"` // For relative: "18 Monate nach Inkrafttreten"
Event string `json:"event,omitempty" yaml:"event,omitempty"` // For on_event: "Bei Sicherheitsvorfall"
Interval string `json:"interval,omitempty" yaml:"interval,omitempty"` // For recurring: "jährlich", "quartalsweise"
}
// SanctionInfo represents potential sanctions for non-compliance
type SanctionInfo struct {
MaxFine string `json:"max_fine,omitempty" yaml:"max_fine,omitempty"` // e.g., "10 Mio. EUR oder 2% Jahresumsatz"
MinFine string `json:"min_fine,omitempty" yaml:"min_fine,omitempty"` // Minimum fine if applicable
PersonalLiability bool `json:"personal_liability" yaml:"personal_liability"` // Can management be held personally liable?
CriminalLiability bool `json:"criminal_liability" yaml:"criminal_liability"` // Can lead to criminal charges?
Description string `json:"description,omitempty" yaml:"description,omitempty"` // Additional description
}
// EvidenceItem represents what constitutes evidence of compliance
type EvidenceItem struct {
ID string `json:"id,omitempty" yaml:"id,omitempty"`
Name string `json:"name" yaml:"name"` // e.g., "Registrierungsbestätigung BSI"
Description string `json:"description,omitempty" yaml:"description,omitempty"` // What this evidence should contain
Format string `json:"format,omitempty" yaml:"format,omitempty"` // e.g., "PDF", "Screenshot", "Protokoll"
Required bool `json:"required" yaml:"required"` // Is this evidence mandatory?
}
// Obligation represents a single regulatory obligation
type Obligation struct {
ID string `json:"id" yaml:"id"` // e.g., "NIS2-OBL-001"
RegulationID string `json:"regulation_id" yaml:"regulation_id"` // e.g., "nis2"
Title string `json:"title" yaml:"title"` // e.g., "BSI-Registrierung"
Description string `json:"description" yaml:"description"` // Detailed description
LegalBasis []LegalReference `json:"legal_basis" yaml:"legal_basis"` // Legal references
Category ObligationCategory `json:"category" yaml:"category"` // Type of obligation
Responsible ResponsibleRole `json:"responsible" yaml:"responsible"` // Who is responsible
Deadline *Deadline `json:"deadline,omitempty" yaml:"deadline,omitempty"`
Sanctions *SanctionInfo `json:"sanctions,omitempty" yaml:"sanctions,omitempty"`
Evidence []EvidenceItem `json:"evidence,omitempty" yaml:"evidence,omitempty"`
Priority ObligationPriority `json:"priority" yaml:"priority"`
Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` // IDs of prerequisite obligations
ISO27001Mapping []string `json:"iso27001_mapping,omitempty" yaml:"iso27001_mapping,omitempty"`
SOC2Mapping []string `json:"soc2_mapping,omitempty" yaml:"soc2_mapping,omitempty"`
AppliesWhen string `json:"applies_when,omitempty" yaml:"applies_when,omitempty"` // Condition expression
// Implementation guidance
HowToImplement string `json:"how_to_implement,omitempty" yaml:"how_to_implement,omitempty"`
BreakpilotFeature string `json:"breakpilot_feature,omitempty" yaml:"breakpilot_feature,omitempty"`
ExternalResources []string `json:"external_resources,omitempty" yaml:"external_resources,omitempty"`
}
// ObligationControl represents a required control/measure
type ObligationControl struct {
ID string `json:"id" yaml:"id"`
RegulationID string `json:"regulation_id" yaml:"regulation_id"`
Name string `json:"name" yaml:"name"`
Description string `json:"description" yaml:"description"`
Category string `json:"category" yaml:"category"`
WhenApplicable string `json:"when_applicable,omitempty" yaml:"when_applicable,omitempty"`
WhatToDo string `json:"what_to_do" yaml:"what_to_do"`
HowToImplement string `json:"how_to_implement,omitempty" yaml:"how_to_implement,omitempty"`
EvidenceNeeded []EvidenceItem `json:"evidence_needed,omitempty" yaml:"evidence_needed,omitempty"`
ISO27001Mapping []string `json:"iso27001_mapping,omitempty" yaml:"iso27001_mapping,omitempty"`
BreakpilotFeature string `json:"breakpilot_feature,omitempty" yaml:"breakpilot_feature,omitempty"`
Priority ObligationPriority `json:"priority" yaml:"priority"`
}
// IncidentDeadline represents a deadline for incident reporting
type IncidentDeadline struct {
RegulationID string `json:"regulation_id" yaml:"regulation_id"`
Phase string `json:"phase" yaml:"phase"` // e.g., "Erstmeldung", "Zwischenbericht"
Deadline string `json:"deadline" yaml:"deadline"` // e.g., "24 Stunden", "72 Stunden"
Content string `json:"content" yaml:"content"` // What must be reported
Recipient string `json:"recipient" yaml:"recipient"` // e.g., "BSI", "Aufsichtsbehörde"
LegalBasis []LegalReference `json:"legal_basis" yaml:"legal_basis"`
AppliesWhen string `json:"applies_when,omitempty" yaml:"applies_when,omitempty"`
}
// DecisionTree represents a decision tree for determining applicability
type DecisionTree struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
RootNode *DecisionNode `json:"root_node" yaml:"root_node"`
Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// DecisionNode represents a node in a decision tree
type DecisionNode struct {
ID string `json:"id" yaml:"id"`
Question string `json:"question,omitempty" yaml:"question,omitempty"`
Condition *ConditionDef `json:"condition,omitempty" yaml:"condition,omitempty"`
YesNode *DecisionNode `json:"yes_node,omitempty" yaml:"yes_node,omitempty"`
NoNode *DecisionNode `json:"no_node,omitempty" yaml:"no_node,omitempty"`
Result string `json:"result,omitempty" yaml:"result,omitempty"` // Terminal node result
Explanation string `json:"explanation,omitempty" yaml:"explanation,omitempty"`
}
// ============================================================================
// Output Structures
// ============================================================================
// ApplicableRegulation represents a regulation that applies to the organization
type ApplicableRegulation struct {
ID string `json:"id"` // e.g., "nis2"
Name string `json:"name"` // e.g., "NIS2-Richtlinie"
Classification string `json:"classification"` // e.g., "wichtige_einrichtung"
Reason string `json:"reason"` // Why this regulation applies
ObligationCount int `json:"obligation_count"` // Number of derived obligations
ControlCount int `json:"control_count"` // Number of required controls
}
// SanctionsSummary aggregates sanction risks across all applicable regulations
type SanctionsSummary struct {
MaxFinancialRisk string `json:"max_financial_risk"` // Highest potential fine
PersonalLiabilityRisk bool `json:"personal_liability_risk"` // Any personal liability?
CriminalLiabilityRisk bool `json:"criminal_liability_risk"` // Any criminal liability?
AffectedRegulations []string `json:"affected_regulations"` // Which regulations have sanctions
Summary string `json:"summary"` // Human-readable summary
}
// ExecutiveSummary provides a C-level overview
type ExecutiveSummary struct {
TotalRegulations int `json:"total_regulations"`
TotalObligations int `json:"total_obligations"`
CriticalObligations int `json:"critical_obligations"`
UpcomingDeadlines int `json:"upcoming_deadlines"` // Deadlines within 30 days
OverdueObligations int `json:"overdue_obligations"` // Past deadline
KeyRisks []string `json:"key_risks"`
RecommendedActions []string `json:"recommended_actions"`
ComplianceScore int `json:"compliance_score"` // 0-100
NextReviewDate *time.Time `json:"next_review_date,omitempty"`
}
// ManagementObligationsOverview is the main output structure for C-Level
type ManagementObligationsOverview struct {
// Metadata
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
OrganizationName string `json:"organization_name"`
AssessmentID string `json:"assessment_id,omitempty"`
AssessmentDate time.Time `json:"assessment_date"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Input facts summary
FactsSummary map[string]interface{} `json:"facts_summary,omitempty"`
// Which regulations apply
ApplicableRegulations []ApplicableRegulation `json:"applicable_regulations"`
// All derived obligations (aggregated from all regulations)
Obligations []Obligation `json:"obligations"`
// All required controls
RequiredControls []ObligationControl `json:"required_controls"`
// Incident reporting deadlines
IncidentDeadlines []IncidentDeadline `json:"incident_deadlines,omitempty"`
// Aggregated sanction risks
SanctionsSummary SanctionsSummary `json:"sanctions_summary"`
// Executive summary for C-Level
ExecutiveSummary ExecutiveSummary `json:"executive_summary"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// ObligationsAssessRequest is the API request for assessing obligations
type ObligationsAssessRequest struct {
Facts *UnifiedFacts `json:"facts"`
OrganizationName string `json:"organization_name,omitempty"`
}
// ObligationsAssessResponse is the API response for obligations assessment
type ObligationsAssessResponse struct {
Overview *ManagementObligationsOverview `json:"overview"`
Warnings []string `json:"warnings,omitempty"`
}
// ObligationsByRegulationResponse groups obligations by regulation
type ObligationsByRegulationResponse struct {
Regulations map[string][]Obligation `json:"regulations"` // regulation_id -> obligations
}
// ObligationsByDeadlineResponse sorts obligations by deadline
type ObligationsByDeadlineResponse struct {
Overdue []Obligation `json:"overdue"`
ThisWeek []Obligation `json:"this_week"`
ThisMonth []Obligation `json:"this_month"`
NextQuarter []Obligation `json:"next_quarter"`
Later []Obligation `json:"later"`
NoDeadline []Obligation `json:"no_deadline"`
}
// ObligationsByResponsibleResponse groups obligations by responsible role
type ObligationsByResponsibleResponse struct {
ByRole map[ResponsibleRole][]Obligation `json:"by_role"`
}
// AvailableRegulationsResponse lists all available regulation modules
type AvailableRegulationsResponse struct {
Regulations []RegulationInfo `json:"regulations"`
}
// RegulationInfo provides info about a regulation module
type RegulationInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Country string `json:"country,omitempty"` // e.g., "DE", "EU"
EffectiveDate string `json:"effective_date,omitempty"`
}
// ExportMemoRequest is the request for exporting a C-Level memo
type ExportMemoRequest struct {
AssessmentID string `json:"assessment_id"`
Format string `json:"format"` // "markdown" or "pdf"
Language string `json:"language,omitempty"` // "de" or "en", default "de"
}
// ExportMemoResponse contains the exported memo
type ExportMemoResponse struct {
Content string `json:"content"` // Markdown or base64-encoded PDF
ContentType string `json:"content_type"` // "text/markdown" or "application/pdf"
Filename string `json:"filename"`
GeneratedAt time.Time `json:"generated_at"`
}
// ============================================================================
// Database Entity for Persistence
// ============================================================================
// ObligationsAssessment represents a stored obligations assessment
type ObligationsAssessment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
OrganizationName string `json:"organization_name"`
Facts *UnifiedFacts `json:"facts"`
Overview *ManagementObligationsOverview `json:"overview"`
Status string `json:"status"` // "draft", "completed"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy uuid.UUID `json:"created_by"`
}

View File

@@ -0,0 +1,480 @@
package ucca
import (
"fmt"
"sort"
"time"
"github.com/google/uuid"
)
// ============================================================================
// Obligations Registry
// ============================================================================
//
// The registry manages all regulation modules and provides methods to evaluate
// facts against all registered regulations, aggregating the results.
//
// ============================================================================
// ObligationsRegistry manages all regulation modules
type ObligationsRegistry struct {
modules map[string]RegulationModule
}
// NewObligationsRegistry creates a new registry and registers all default modules
func NewObligationsRegistry() *ObligationsRegistry {
r := &ObligationsRegistry{
modules: make(map[string]RegulationModule),
}
// Register default modules
// NIS2 module
nis2Module, err := NewNIS2Module()
if err != nil {
fmt.Printf("Warning: Could not load NIS2 module: %v\n", err)
} else {
r.Register(nis2Module)
}
// DSGVO module
dsgvoModule, err := NewDSGVOModule()
if err != nil {
fmt.Printf("Warning: Could not load DSGVO module: %v\n", err)
} else {
r.Register(dsgvoModule)
}
// AI Act module
aiActModule, err := NewAIActModule()
if err != nil {
fmt.Printf("Warning: Could not load AI Act module: %v\n", err)
} else {
r.Register(aiActModule)
}
// Future modules will be registered here:
// r.Register(NewDORAModule())
return r
}
// NewObligationsRegistryWithModules creates a registry with specific modules
func NewObligationsRegistryWithModules(modules ...RegulationModule) *ObligationsRegistry {
r := &ObligationsRegistry{
modules: make(map[string]RegulationModule),
}
for _, m := range modules {
r.Register(m)
}
return r
}
// Register adds a regulation module to the registry
func (r *ObligationsRegistry) Register(module RegulationModule) {
r.modules[module.ID()] = module
}
// Unregister removes a regulation module from the registry
func (r *ObligationsRegistry) Unregister(moduleID string) {
delete(r.modules, moduleID)
}
// GetModule returns a specific module by ID
func (r *ObligationsRegistry) GetModule(moduleID string) (RegulationModule, bool) {
m, ok := r.modules[moduleID]
return m, ok
}
// ListModules returns info about all registered modules
func (r *ObligationsRegistry) ListModules() []RegulationInfo {
var result []RegulationInfo
for _, m := range r.modules {
result = append(result, RegulationInfo{
ID: m.ID(),
Name: m.Name(),
Description: m.Description(),
})
}
// Sort by ID for consistent output
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result
}
// EvaluateAll evaluates all registered modules against the given facts
func (r *ObligationsRegistry) EvaluateAll(tenantID uuid.UUID, facts *UnifiedFacts, orgName string) *ManagementObligationsOverview {
overview := &ManagementObligationsOverview{
ID: uuid.New(),
TenantID: tenantID,
OrganizationName: orgName,
AssessmentDate: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ApplicableRegulations: []ApplicableRegulation{},
Obligations: []Obligation{},
RequiredControls: []ObligationControl{},
IncidentDeadlines: []IncidentDeadline{},
}
// Track aggregated sanctions
var maxFine string
var personalLiability, criminalLiability bool
var affectedRegulations []string
// Evaluate each module
for _, module := range r.modules {
if module.IsApplicable(facts) {
// Get classification
classification := module.GetClassification(facts)
// Derive obligations
obligations := module.DeriveObligations(facts)
// Derive controls
controls := module.DeriveControls(facts)
// Get incident deadlines
incidentDeadlines := module.GetIncidentDeadlines(facts)
// Add to applicable regulations
overview.ApplicableRegulations = append(overview.ApplicableRegulations, ApplicableRegulation{
ID: module.ID(),
Name: module.Name(),
Classification: classification,
Reason: r.getApplicabilityReason(module, facts, classification),
ObligationCount: len(obligations),
ControlCount: len(controls),
})
// Aggregate obligations
overview.Obligations = append(overview.Obligations, obligations...)
// Aggregate controls
overview.RequiredControls = append(overview.RequiredControls, controls...)
// Aggregate incident deadlines
overview.IncidentDeadlines = append(overview.IncidentDeadlines, incidentDeadlines...)
// Track sanctions
for _, obl := range obligations {
if obl.Sanctions != nil {
if obl.Sanctions.MaxFine != "" && (maxFine == "" || len(obl.Sanctions.MaxFine) > len(maxFine)) {
maxFine = obl.Sanctions.MaxFine
}
if obl.Sanctions.PersonalLiability {
personalLiability = true
}
if obl.Sanctions.CriminalLiability {
criminalLiability = true
}
if !containsString(affectedRegulations, module.ID()) {
affectedRegulations = append(affectedRegulations, module.ID())
}
}
}
}
}
// Sort obligations by priority and deadline
r.sortObligations(overview)
// Build sanctions summary
overview.SanctionsSummary = r.buildSanctionsSummary(maxFine, personalLiability, criminalLiability, affectedRegulations)
// Generate executive summary
overview.ExecutiveSummary = r.generateExecutiveSummary(overview)
return overview
}
// EvaluateSingle evaluates a single module against the given facts
func (r *ObligationsRegistry) EvaluateSingle(moduleID string, facts *UnifiedFacts) (*ManagementObligationsOverview, error) {
module, ok := r.modules[moduleID]
if !ok {
return nil, fmt.Errorf("module not found: %s", moduleID)
}
overview := &ManagementObligationsOverview{
ID: uuid.New(),
AssessmentDate: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ApplicableRegulations: []ApplicableRegulation{},
Obligations: []Obligation{},
RequiredControls: []ObligationControl{},
IncidentDeadlines: []IncidentDeadline{},
}
if !module.IsApplicable(facts) {
return overview, nil
}
classification := module.GetClassification(facts)
obligations := module.DeriveObligations(facts)
controls := module.DeriveControls(facts)
incidentDeadlines := module.GetIncidentDeadlines(facts)
overview.ApplicableRegulations = append(overview.ApplicableRegulations, ApplicableRegulation{
ID: module.ID(),
Name: module.Name(),
Classification: classification,
Reason: r.getApplicabilityReason(module, facts, classification),
ObligationCount: len(obligations),
ControlCount: len(controls),
})
overview.Obligations = obligations
overview.RequiredControls = controls
overview.IncidentDeadlines = incidentDeadlines
r.sortObligations(overview)
overview.ExecutiveSummary = r.generateExecutiveSummary(overview)
return overview, nil
}
// GetDecisionTree returns the decision tree for a specific module
func (r *ObligationsRegistry) GetDecisionTree(moduleID string) (*DecisionTree, error) {
module, ok := r.modules[moduleID]
if !ok {
return nil, fmt.Errorf("module not found: %s", moduleID)
}
tree := module.GetDecisionTree()
if tree == nil {
return nil, fmt.Errorf("module %s does not have a decision tree", moduleID)
}
return tree, nil
}
// ============================================================================
// Helper Methods
// ============================================================================
func (r *ObligationsRegistry) getApplicabilityReason(module RegulationModule, facts *UnifiedFacts, classification string) string {
switch module.ID() {
case "nis2":
if classification == string(NIS2EssentialEntity) {
return "Besonders wichtige Einrichtung aufgrund von Sektor und Größe"
} else if classification == string(NIS2ImportantEntity) {
return "Wichtige Einrichtung aufgrund von Sektor und Größe"
}
return "NIS2-Richtlinie anwendbar"
case "dsgvo":
return "Verarbeitung personenbezogener Daten"
case "ai_act":
return "Einsatz von KI-Systemen"
case "dora":
return "Reguliertes Finanzunternehmen"
default:
return "Regulierung anwendbar"
}
}
func (r *ObligationsRegistry) sortObligations(overview *ManagementObligationsOverview) {
// Sort by priority (critical first), then by deadline
priorityOrder := map[ObligationPriority]int{
PriorityCritical: 0,
PriorityHigh: 1,
PriorityMedium: 2,
PriorityLow: 3,
}
sort.Slice(overview.Obligations, func(i, j int) bool {
// First by priority
pi := priorityOrder[overview.Obligations[i].Priority]
pj := priorityOrder[overview.Obligations[j].Priority]
if pi != pj {
return pi < pj
}
// Then by deadline (earlier first, nil last)
di := overview.Obligations[i].Deadline
dj := overview.Obligations[j].Deadline
if di == nil && dj == nil {
return false
}
if di == nil {
return false
}
if dj == nil {
return true
}
// For absolute deadlines, compare dates
if di.Type == DeadlineAbsolute && dj.Type == DeadlineAbsolute {
if di.Date != nil && dj.Date != nil {
return di.Date.Before(*dj.Date)
}
}
return false
})
}
func (r *ObligationsRegistry) buildSanctionsSummary(maxFine string, personal, criminal bool, affected []string) SanctionsSummary {
var summary string
if personal && criminal {
summary = "Hohe Bußgelder möglich. Persönliche Haftung der Geschäftsführung sowie strafrechtliche Konsequenzen bei Verstößen."
} else if personal {
summary = "Hohe Bußgelder möglich. Persönliche Haftung der Geschäftsführung bei Verstößen."
} else if maxFine != "" {
summary = fmt.Sprintf("Bußgelder bis zu %s bei Verstößen möglich.", maxFine)
} else {
summary = "Keine spezifischen Sanktionen identifiziert."
}
return SanctionsSummary{
MaxFinancialRisk: maxFine,
PersonalLiabilityRisk: personal,
CriminalLiabilityRisk: criminal,
AffectedRegulations: affected,
Summary: summary,
}
}
func (r *ObligationsRegistry) generateExecutiveSummary(overview *ManagementObligationsOverview) ExecutiveSummary {
summary := ExecutiveSummary{
TotalRegulations: len(overview.ApplicableRegulations),
TotalObligations: len(overview.Obligations),
CriticalObligations: 0,
UpcomingDeadlines: 0,
OverdueObligations: 0,
KeyRisks: []string{},
RecommendedActions: []string{},
ComplianceScore: 100, // Start at 100, deduct for gaps
}
now := time.Now()
thirtyDaysFromNow := now.AddDate(0, 0, 30)
for _, obl := range overview.Obligations {
// Count critical
if obl.Priority == PriorityCritical {
summary.CriticalObligations++
summary.ComplianceScore -= 10
}
// Count deadlines
if obl.Deadline != nil && obl.Deadline.Type == DeadlineAbsolute && obl.Deadline.Date != nil {
if obl.Deadline.Date.Before(now) {
summary.OverdueObligations++
summary.ComplianceScore -= 15
} else if obl.Deadline.Date.Before(thirtyDaysFromNow) {
summary.UpcomingDeadlines++
}
}
}
// Ensure score doesn't go below 0
if summary.ComplianceScore < 0 {
summary.ComplianceScore = 0
}
// Add key risks
if summary.CriticalObligations > 0 {
summary.KeyRisks = append(summary.KeyRisks, fmt.Sprintf("%d kritische Pflichten erfordern sofortige Aufmerksamkeit", summary.CriticalObligations))
}
if summary.OverdueObligations > 0 {
summary.KeyRisks = append(summary.KeyRisks, fmt.Sprintf("%d Pflichten haben überfällige Fristen", summary.OverdueObligations))
}
if overview.SanctionsSummary.PersonalLiabilityRisk {
summary.KeyRisks = append(summary.KeyRisks, "Persönliche Haftungsrisiken für die Geschäftsführung bestehen")
}
// Add recommended actions
if summary.OverdueObligations > 0 {
summary.RecommendedActions = append(summary.RecommendedActions, "Überfällige Pflichten priorisieren und umgehend adressieren")
}
if summary.CriticalObligations > 0 {
summary.RecommendedActions = append(summary.RecommendedActions, "Kritische Pflichten in der nächsten Vorstandssitzung besprechen")
}
if len(overview.IncidentDeadlines) > 0 {
summary.RecommendedActions = append(summary.RecommendedActions, "Incident-Response-Prozesse gemäß Meldefristen etablieren")
}
// Default action if no specific risks
if len(summary.RecommendedActions) == 0 {
summary.RecommendedActions = append(summary.RecommendedActions, "Regelmäßige Compliance-Reviews durchführen")
}
// Set next review date (3 months from now)
nextReview := now.AddDate(0, 3, 0)
summary.NextReviewDate = &nextReview
return summary
}
// containsString checks if a slice contains a string
func containsString(slice []string, s string) bool {
for _, item := range slice {
if item == s {
return true
}
}
return false
}
// ============================================================================
// Grouping Methods
// ============================================================================
// GroupByRegulation groups obligations by their regulation ID
func (r *ObligationsRegistry) GroupByRegulation(obligations []Obligation) map[string][]Obligation {
result := make(map[string][]Obligation)
for _, obl := range obligations {
result[obl.RegulationID] = append(result[obl.RegulationID], obl)
}
return result
}
// GroupByDeadline groups obligations by deadline timeframe
func (r *ObligationsRegistry) GroupByDeadline(obligations []Obligation) ObligationsByDeadlineResponse {
result := ObligationsByDeadlineResponse{
Overdue: []Obligation{},
ThisWeek: []Obligation{},
ThisMonth: []Obligation{},
NextQuarter: []Obligation{},
Later: []Obligation{},
NoDeadline: []Obligation{},
}
now := time.Now()
oneWeek := now.AddDate(0, 0, 7)
oneMonth := now.AddDate(0, 1, 0)
threeMonths := now.AddDate(0, 3, 0)
for _, obl := range obligations {
if obl.Deadline == nil || obl.Deadline.Type != DeadlineAbsolute || obl.Deadline.Date == nil {
result.NoDeadline = append(result.NoDeadline, obl)
continue
}
deadline := *obl.Deadline.Date
switch {
case deadline.Before(now):
result.Overdue = append(result.Overdue, obl)
case deadline.Before(oneWeek):
result.ThisWeek = append(result.ThisWeek, obl)
case deadline.Before(oneMonth):
result.ThisMonth = append(result.ThisMonth, obl)
case deadline.Before(threeMonths):
result.NextQuarter = append(result.NextQuarter, obl)
default:
result.Later = append(result.Later, obl)
}
}
return result
}
// GroupByResponsible groups obligations by responsible role
func (r *ObligationsRegistry) GroupByResponsible(obligations []Obligation) map[ResponsibleRole][]Obligation {
result := make(map[ResponsibleRole][]Obligation)
for _, obl := range obligations {
result[obl.Responsible] = append(result[obl.Responsible], obl)
}
return result
}

View File

@@ -0,0 +1,216 @@
package ucca
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// ObligationsStore handles persistence of obligations assessments
type ObligationsStore struct {
pool *pgxpool.Pool
}
// NewObligationsStore creates a new ObligationsStore
func NewObligationsStore(pool *pgxpool.Pool) *ObligationsStore {
return &ObligationsStore{pool: pool}
}
// CreateAssessment persists a new obligations assessment
func (s *ObligationsStore) CreateAssessment(ctx context.Context, a *ObligationsAssessment) error {
if a.ID == uuid.Nil {
a.ID = uuid.New()
}
a.CreatedAt = time.Now().UTC()
a.UpdatedAt = a.CreatedAt
// Marshal JSON fields
factsJSON, err := json.Marshal(a.Facts)
if err != nil {
return fmt.Errorf("failed to marshal facts: %w", err)
}
overviewJSON, err := json.Marshal(a.Overview)
if err != nil {
return fmt.Errorf("failed to marshal overview: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO obligations_assessments (
id, tenant_id, organization_name, facts, overview,
status, created_at, updated_at, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
a.ID, a.TenantID, a.OrganizationName, factsJSON, overviewJSON,
a.Status, a.CreatedAt, a.UpdatedAt, a.CreatedBy,
)
if err != nil {
return fmt.Errorf("failed to insert assessment: %w", err)
}
return nil
}
// GetAssessment retrieves an assessment by ID
func (s *ObligationsStore) GetAssessment(ctx context.Context, tenantID, assessmentID uuid.UUID) (*ObligationsAssessment, error) {
var a ObligationsAssessment
var factsJSON, overviewJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, organization_name, facts, overview,
status, created_at, updated_at, created_by
FROM obligations_assessments
WHERE id = $1 AND tenant_id = $2
`, assessmentID, tenantID).Scan(
&a.ID, &a.TenantID, &a.OrganizationName, &factsJSON, &overviewJSON,
&a.Status, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("assessment not found: %w", err)
}
// Unmarshal JSON fields
if err := json.Unmarshal(factsJSON, &a.Facts); err != nil {
return nil, fmt.Errorf("failed to unmarshal facts: %w", err)
}
if err := json.Unmarshal(overviewJSON, &a.Overview); err != nil {
return nil, fmt.Errorf("failed to unmarshal overview: %w", err)
}
return &a, nil
}
// ListAssessments returns all assessments for a tenant
func (s *ObligationsStore) ListAssessments(ctx context.Context, tenantID uuid.UUID, limit, offset int) ([]*ObligationsAssessment, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, organization_name, facts, overview,
status, created_at, updated_at, created_by
FROM obligations_assessments
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, tenantID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to query assessments: %w", err)
}
defer rows.Close()
var assessments []*ObligationsAssessment
for rows.Next() {
var a ObligationsAssessment
var factsJSON, overviewJSON []byte
if err := rows.Scan(
&a.ID, &a.TenantID, &a.OrganizationName, &factsJSON, &overviewJSON,
&a.Status, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy,
); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
if err := json.Unmarshal(factsJSON, &a.Facts); err != nil {
return nil, fmt.Errorf("failed to unmarshal facts: %w", err)
}
if err := json.Unmarshal(overviewJSON, &a.Overview); err != nil {
return nil, fmt.Errorf("failed to unmarshal overview: %w", err)
}
assessments = append(assessments, &a)
}
return assessments, nil
}
// UpdateAssessment updates an existing assessment
func (s *ObligationsStore) UpdateAssessment(ctx context.Context, a *ObligationsAssessment) error {
a.UpdatedAt = time.Now().UTC()
factsJSON, err := json.Marshal(a.Facts)
if err != nil {
return fmt.Errorf("failed to marshal facts: %w", err)
}
overviewJSON, err := json.Marshal(a.Overview)
if err != nil {
return fmt.Errorf("failed to marshal overview: %w", err)
}
result, err := s.pool.Exec(ctx, `
UPDATE obligations_assessments
SET organization_name = $3, facts = $4, overview = $5,
status = $6, updated_at = $7
WHERE id = $1 AND tenant_id = $2
`,
a.ID, a.TenantID, a.OrganizationName, factsJSON, overviewJSON,
a.Status, a.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to update assessment: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("assessment not found")
}
return nil
}
// DeleteAssessment deletes an assessment
func (s *ObligationsStore) DeleteAssessment(ctx context.Context, tenantID, assessmentID uuid.UUID) error {
result, err := s.pool.Exec(ctx, `
DELETE FROM obligations_assessments
WHERE id = $1 AND tenant_id = $2
`, assessmentID, tenantID)
if err != nil {
return fmt.Errorf("failed to delete assessment: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("assessment not found")
}
return nil
}
// GetMigrationSQL returns the SQL to create the required table
func (s *ObligationsStore) GetMigrationSQL() string {
return `
-- Obligations Assessments Table
CREATE TABLE IF NOT EXISTS obligations_assessments (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
organization_name VARCHAR(255),
facts JSONB NOT NULL,
overview JSONB NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'completed',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_by UUID,
CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_obligations_assessments_tenant ON obligations_assessments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_obligations_assessments_created ON obligations_assessments(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_obligations_assessments_status ON obligations_assessments(status);
-- GIN index for JSON queries
CREATE INDEX IF NOT EXISTS idx_obligations_assessments_overview ON obligations_assessments USING GIN (overview);
`
}

View File

@@ -0,0 +1,279 @@
package ucca
// ============================================================================
// Architecture Patterns - Hardcoded recommendations
// ============================================================================
// Pattern represents an architecture pattern recommendation
type Pattern struct {
ID string `json:"id"`
Title string `json:"title"`
TitleDE string `json:"title_de"`
Description string `json:"description"`
DescriptionDE string `json:"description_de"`
Benefits []string `json:"benefits"`
Requirements []string `json:"requirements"`
ApplicableWhen func(intake *UseCaseIntake) bool `json:"-"`
}
// PatternLibrary contains all available architecture patterns
var PatternLibrary = []Pattern{
{
ID: "P-RAG-ONLY",
Title: "RAG-Only Architecture",
TitleDE: "Nur-RAG-Architektur",
Description: "Use Retrieval-Augmented Generation without storing or training on personal data. The LLM only receives anonymized context from a vector database, never raw personal data.",
DescriptionDE: "Nutzen Sie Retrieval-Augmented Generation ohne Speicherung oder Training mit personenbezogenen Daten. Das LLM erhält nur anonymisierten Kontext aus einer Vektor-Datenbank, niemals Rohdaten.",
Benefits: []string{
"No personal data in model weights",
"Easy to delete/update information",
"Audit trail for all retrievals",
"Minimal GDPR compliance burden",
},
Requirements: []string{
"Vector database with access controls",
"Pre-anonymization pipeline",
"Query logging for audit",
},
ApplicableWhen: func(intake *UseCaseIntake) bool {
// Applicable when using RAG and dealing with personal data
return intake.ModelUsage.RAG && (intake.DataTypes.PersonalData || intake.DataTypes.CustomerData)
},
},
{
ID: "P-PRE-ANON",
Title: "Pre-Anonymization Pipeline",
TitleDE: "Vor-Anonymisierungs-Pipeline",
Description: "Implement mandatory anonymization/pseudonymization before any data reaches the AI system. Personal identifiers are replaced with tokens that can be resolved only by authorized systems.",
DescriptionDE: "Implementieren Sie eine verpflichtende Anonymisierung/Pseudonymisierung bevor Daten das KI-System erreichen. Persönliche Identifikatoren werden durch Tokens ersetzt, die nur von autorisierten Systemen aufgelöst werden können.",
Benefits: []string{
"Personal data never reaches AI",
"Reversible for authorized access",
"Compliant with Art. 32 GDPR",
"Reduces data breach impact",
},
Requirements: []string{
"PII detection system",
"Token mapping database (secured)",
"Re-identification access controls",
"Audit logging for re-identification",
},
ApplicableWhen: func(intake *UseCaseIntake) bool {
// Applicable when handling personal data and not public data only
return intake.DataTypes.PersonalData && !intake.DataTypes.PublicData
},
},
{
ID: "P-NAMESPACE-ISOLATION",
Title: "Namespace Isolation",
TitleDE: "Namespace-Isolation",
Description: "Strict separation of data and models per tenant/department. Each namespace has its own vector store, model context, and access controls. No data leakage between tenants.",
DescriptionDE: "Strikte Trennung von Daten und Modellen pro Mandant/Abteilung. Jeder Namespace hat eigenen Vektor-Speicher, Modellkontext und Zugriffskontrollen. Keine Datenlecks zwischen Mandanten.",
Benefits: []string{
"Multi-tenant compliance",
"Clear data ownership",
"Easy audit per namespace",
"Deletion per namespace",
},
Requirements: []string{
"Multi-tenant architecture",
"Namespace-aware APIs",
"Access control per namespace",
"Separate encryption keys",
},
ApplicableWhen: func(intake *UseCaseIntake) bool {
// Applicable for multi-tenant scenarios or when handling different data types
return intake.DataTypes.CustomerData || intake.DataTypes.EmployeeData
},
},
{
ID: "P-HITL-ENFORCED",
Title: "Human-in-the-Loop Enforcement",
TitleDE: "Verpflichtende menschliche Kontrolle",
Description: "Mandatory human review before any AI output affects individuals. The AI provides suggestions, but final decisions require human approval. Audit trail for all decisions.",
DescriptionDE: "Verpflichtende menschliche Überprüfung bevor KI-Ausgaben Personen betreffen. Die KI liefert Vorschläge, aber finale Entscheidungen erfordern menschliche Genehmigung. Prüfpfad für alle Entscheidungen.",
Benefits: []string{
"Art. 22 GDPR compliance",
"Accountability clear",
"Error correction possible",
"Human judgment preserved",
},
Requirements: []string{
"Review workflow system",
"Decision audit logging",
"Clear escalation paths",
"Training for reviewers",
},
ApplicableWhen: func(intake *UseCaseIntake) bool {
// Applicable when outputs have legal effects or involve scoring/decisions
return intake.Outputs.LegalEffects ||
intake.Outputs.RankingsOrScores ||
intake.Outputs.AccessDecisions ||
intake.Purpose.EvaluationScoring ||
intake.Purpose.DecisionMaking
},
},
{
ID: "P-LOG-MINIMIZATION",
Title: "Log Minimization",
TitleDE: "Log-Minimierung",
Description: "Minimize logging of personal data in AI interactions. Store only anonymized metrics, not raw prompts/responses. Implement automatic log rotation and deletion.",
DescriptionDE: "Minimieren Sie die Protokollierung personenbezogener Daten in KI-Interaktionen. Speichern Sie nur anonymisierte Metriken, keine Roh-Prompts/Antworten. Implementieren Sie automatische Log-Rotation und Löschung.",
Benefits: []string{
"Data minimization (Art. 5c GDPR)",
"Reduced storage costs",
"Simplified deletion requests",
"Lower breach impact",
},
Requirements: []string{
"Log anonymization pipeline",
"Automatic log rotation",
"Metrics without PII",
"Retention policy enforcement",
},
ApplicableWhen: func(intake *UseCaseIntake) bool {
// Applicable when storing prompts/responses
return intake.Retention.StorePrompts || intake.Retention.StoreResponses
},
},
}
// GetApplicablePatterns returns patterns applicable for the given intake
func GetApplicablePatterns(intake *UseCaseIntake) []Pattern {
var applicable []Pattern
for _, p := range PatternLibrary {
if p.ApplicableWhen(intake) {
applicable = append(applicable, p)
}
}
return applicable
}
// GetPatternByID returns a pattern by its ID
func GetPatternByID(id string) *Pattern {
for _, p := range PatternLibrary {
if p.ID == id {
return &p
}
}
return nil
}
// GetAllPatterns returns all available patterns
func GetAllPatterns() []Pattern {
return PatternLibrary
}
// PatternToRecommendation converts a Pattern to a PatternRecommendation
func PatternToRecommendation(p Pattern, rationale string, priority int) PatternRecommendation {
return PatternRecommendation{
PatternID: p.ID,
Title: p.TitleDE,
Description: p.DescriptionDE,
Rationale: rationale,
Priority: priority,
}
}
// ============================================================================
// Forbidden Pattern Definitions
// ============================================================================
// ForbiddenPatternDef defines patterns that should NOT be used
type ForbiddenPatternDef struct {
ID string
Title string
TitleDE string
Description string
DescriptionDE string
Reason string
ReasonDE string
GDPRRef string
ForbiddenWhen func(intake *UseCaseIntake) bool
}
// ForbiddenPatternLibrary contains all forbidden pattern definitions
var ForbiddenPatternLibrary = []ForbiddenPatternDef{
{
ID: "FP-DIRECT-PII-TRAINING",
Title: "Direct PII Training",
TitleDE: "Direktes Training mit personenbezogenen Daten",
Description: "Training AI models directly on personal data without proper legal basis or consent.",
DescriptionDE: "Training von KI-Modellen direkt mit personenbezogenen Daten ohne ausreichende Rechtsgrundlage oder Einwilligung.",
Reason: "Violates GDPR purpose limitation and data minimization principles.",
ReasonDE: "Verstößt gegen DSGVO-Grundsätze der Zweckbindung und Datenminimierung.",
GDPRRef: "Art. 5(1)(b)(c) DSGVO",
ForbiddenWhen: func(intake *UseCaseIntake) bool {
return intake.ModelUsage.Training && intake.DataTypes.PersonalData
},
},
{
ID: "FP-ART9-WITHOUT-CONSENT",
Title: "Special Category Data Without Explicit Consent",
TitleDE: "Besondere Kategorien ohne ausdrückliche Einwilligung",
Description: "Processing special category data (health, religion, etc.) without explicit consent or legal basis.",
DescriptionDE: "Verarbeitung besonderer Datenkategorien (Gesundheit, Religion, etc.) ohne ausdrückliche Einwilligung oder Rechtsgrundlage.",
Reason: "Article 9 data requires explicit consent or specific legal basis.",
ReasonDE: "Art. 9-Daten erfordern ausdrückliche Einwilligung oder spezifische Rechtsgrundlage.",
GDPRRef: "Art. 9 DSGVO",
ForbiddenWhen: func(intake *UseCaseIntake) bool {
return intake.DataTypes.Article9Data
},
},
{
ID: "FP-AUTOMATED-LEGAL-DECISION",
Title: "Fully Automated Legal Decisions",
TitleDE: "Vollautomatisierte rechtliche Entscheidungen",
Description: "Making fully automated decisions with legal effects without human oversight.",
DescriptionDE: "Treffen vollautomatisierter Entscheidungen mit rechtlichen Auswirkungen ohne menschliche Aufsicht.",
Reason: "Article 22 GDPR restricts automated individual decision-making.",
ReasonDE: "Art. 22 DSGVO beschränkt automatisierte Einzelentscheidungen.",
GDPRRef: "Art. 22 DSGVO",
ForbiddenWhen: func(intake *UseCaseIntake) bool {
return intake.Automation == AutomationFullyAutomated && intake.Outputs.LegalEffects
},
},
{
ID: "FP-MINOR-PROFILING",
Title: "Profiling of Minors",
TitleDE: "Profiling von Minderjährigen",
Description: "Creating profiles or scores for children/minors.",
DescriptionDE: "Erstellen von Profilen oder Scores für Kinder/Minderjährige.",
Reason: "Special protection for minors under GDPR.",
ReasonDE: "Besonderer Schutz für Minderjährige unter der DSGVO.",
GDPRRef: "Art. 8, Erwägungsgrund 38 DSGVO",
ForbiddenWhen: func(intake *UseCaseIntake) bool {
return intake.DataTypes.MinorData && (intake.Purpose.Profiling || intake.Purpose.EvaluationScoring)
},
},
{
ID: "FP-THIRD-COUNTRY-PII",
Title: "PII Transfer to Third Countries",
TitleDE: "Übermittlung personenbezogener Daten in Drittländer",
Description: "Transferring personal data to third countries without adequate safeguards.",
DescriptionDE: "Übermittlung personenbezogener Daten in Drittländer ohne angemessene Schutzmaßnahmen.",
Reason: "Requires adequacy decision or appropriate safeguards.",
ReasonDE: "Erfordert Angemessenheitsbeschluss oder geeignete Garantien.",
GDPRRef: "Art. 44-49 DSGVO",
ForbiddenWhen: func(intake *UseCaseIntake) bool {
return intake.Hosting.Region == "third_country" && intake.DataTypes.PersonalData
},
},
}
// GetForbiddenPatterns returns forbidden patterns for the given intake
func GetForbiddenPatterns(intake *UseCaseIntake) []ForbiddenPattern {
var forbidden []ForbiddenPattern
for _, fp := range ForbiddenPatternLibrary {
if fp.ForbiddenWhen(intake) {
forbidden = append(forbidden, ForbiddenPattern{
PatternID: fp.ID,
Title: fp.TitleDE,
Description: fp.DescriptionDE,
Reason: fp.ReasonDE,
GDPRRef: fp.GDPRRef,
})
}
}
return forbidden
}

View File

@@ -0,0 +1,510 @@
package ucca
import (
"bytes"
"encoding/base64"
"fmt"
"time"
"github.com/jung-kurt/gofpdf"
)
// PDFExporter generates PDF documents from obligations assessments
type PDFExporter struct {
language string
}
// NewPDFExporter creates a new PDF exporter
func NewPDFExporter(language string) *PDFExporter {
if language == "" {
language = "de"
}
return &PDFExporter{language: language}
}
// ExportManagementMemo exports the management obligations overview as a PDF
func (e *PDFExporter) ExportManagementMemo(overview *ManagementObligationsOverview) (*ExportMemoResponse, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
// Set UTF-8 support with DejaVu font (fallback to core fonts)
pdf.SetFont("Helvetica", "", 12)
// Add first page
pdf.AddPage()
// Add title
e.addTitle(pdf, overview)
// Add executive summary
e.addExecutiveSummary(pdf, overview)
// Add applicable regulations
e.addApplicableRegulations(pdf, overview)
// Add sanctions summary
e.addSanctionsSummary(pdf, overview)
// Add obligations table
e.addObligationsTable(pdf, overview)
// Add incident deadlines if present
if len(overview.IncidentDeadlines) > 0 {
e.addIncidentDeadlines(pdf, overview)
}
// Add footer with generation date
e.addFooter(pdf, overview)
// Generate PDF bytes
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("failed to generate PDF: %w", err)
}
// Encode as base64
content := base64.StdEncoding.EncodeToString(buf.Bytes())
return &ExportMemoResponse{
Content: content,
ContentType: "application/pdf",
Filename: fmt.Sprintf("pflichten-uebersicht-%s.pdf", time.Now().Format("2006-01-02")),
GeneratedAt: time.Now(),
}, nil
}
// addTitle adds the document title
func (e *PDFExporter) addTitle(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
pdf.SetFont("Helvetica", "B", 24)
pdf.SetTextColor(0, 0, 0)
title := "Regulatorische Pflichten-Uebersicht"
if e.language == "en" {
title = "Regulatory Obligations Overview"
}
pdf.CellFormat(0, 15, title, "", 1, "C", false, 0, "")
// Organization name
if overview.OrganizationName != "" {
pdf.SetFont("Helvetica", "", 14)
pdf.CellFormat(0, 10, overview.OrganizationName, "", 1, "C", false, 0, "")
}
// Date
pdf.SetFont("Helvetica", "I", 10)
dateStr := overview.AssessmentDate.Format("02.01.2006")
pdf.CellFormat(0, 8, fmt.Sprintf("Stand: %s", dateStr), "", 1, "C", false, 0, "")
pdf.Ln(10)
}
// addExecutiveSummary adds the executive summary section
func (e *PDFExporter) addExecutiveSummary(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
e.addSectionHeader(pdf, "Executive Summary")
summary := overview.ExecutiveSummary
// Stats table
pdf.SetFont("Helvetica", "", 11)
stats := []struct {
label string
value string
}{
{"Anwendbare Regulierungen", fmt.Sprintf("%d", summary.TotalRegulations)},
{"Gesamtzahl Pflichten", fmt.Sprintf("%d", summary.TotalObligations)},
{"Kritische Pflichten", fmt.Sprintf("%d", summary.CriticalObligations)},
{"Kommende Fristen (30 Tage)", fmt.Sprintf("%d", summary.UpcomingDeadlines)},
{"Compliance Score", fmt.Sprintf("%d%%", summary.ComplianceScore)},
}
for _, stat := range stats {
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(80, 7, stat.label+":", "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
pdf.CellFormat(0, 7, stat.value, "", 1, "L", false, 0, "")
}
// Key risks
if len(summary.KeyRisks) > 0 {
pdf.Ln(5)
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(0, 7, "Wesentliche Risiken:", "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
for _, risk := range summary.KeyRisks {
pdf.CellFormat(10, 6, "", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, "- "+risk, "", 1, "L", false, 0, "")
}
}
// Recommended actions
if len(summary.RecommendedActions) > 0 {
pdf.Ln(5)
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(0, 7, "Empfohlene Massnahmen:", "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
for _, action := range summary.RecommendedActions {
pdf.CellFormat(10, 6, "", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, "- "+action, "", 1, "L", false, 0, "")
}
}
pdf.Ln(10)
}
// addApplicableRegulations adds the applicable regulations section
func (e *PDFExporter) addApplicableRegulations(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
e.addSectionHeader(pdf, "Anwendbare Regulierungen")
pdf.SetFont("Helvetica", "", 10)
// Table header
pdf.SetFillColor(240, 240, 240)
pdf.SetFont("Helvetica", "B", 10)
pdf.CellFormat(60, 8, "Regulierung", "1", 0, "L", true, 0, "")
pdf.CellFormat(50, 8, "Klassifizierung", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 8, "Pflichten", "1", 0, "C", true, 0, "")
pdf.CellFormat(50, 8, "Grund", "1", 1, "L", true, 0, "")
pdf.SetFont("Helvetica", "", 10)
for _, reg := range overview.ApplicableRegulations {
pdf.CellFormat(60, 7, reg.Name, "1", 0, "L", false, 0, "")
pdf.CellFormat(50, 7, truncateString(reg.Classification, 25), "1", 0, "L", false, 0, "")
pdf.CellFormat(30, 7, fmt.Sprintf("%d", reg.ObligationCount), "1", 0, "C", false, 0, "")
pdf.CellFormat(50, 7, truncateString(reg.Reason, 25), "1", 1, "L", false, 0, "")
}
pdf.Ln(10)
}
// addSanctionsSummary adds the sanctions summary section
func (e *PDFExporter) addSanctionsSummary(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
e.addSectionHeader(pdf, "Sanktionsrisiken")
sanctions := overview.SanctionsSummary
pdf.SetFont("Helvetica", "", 11)
// Max financial risk
if sanctions.MaxFinancialRisk != "" {
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(60, 7, "Max. Finanzrisiko:", "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
pdf.SetTextColor(200, 0, 0)
pdf.CellFormat(0, 7, sanctions.MaxFinancialRisk, "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
}
// Personal liability
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(60, 7, "Persoenliche Haftung:", "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
liabilityText := "Nein"
if sanctions.PersonalLiabilityRisk {
liabilityText = "Ja - Geschaeftsfuehrung kann persoenlich haften"
pdf.SetTextColor(200, 0, 0)
}
pdf.CellFormat(0, 7, liabilityText, "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
// Criminal liability
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(60, 7, "Strafrechtliche Konsequenzen:", "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 11)
criminalText := "Nein"
if sanctions.CriminalLiabilityRisk {
criminalText = "Moeglich"
pdf.SetTextColor(200, 0, 0)
}
pdf.CellFormat(0, 7, criminalText, "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
// Summary
if sanctions.Summary != "" {
pdf.Ln(3)
pdf.SetFont("Helvetica", "I", 10)
pdf.MultiCell(0, 5, sanctions.Summary, "", "L", false)
}
pdf.Ln(10)
}
// addObligationsTable adds the obligations table
func (e *PDFExporter) addObligationsTable(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
e.addSectionHeader(pdf, "Pflichten-Uebersicht")
// Group by priority
criticalObls := []Obligation{}
highObls := []Obligation{}
otherObls := []Obligation{}
for _, obl := range overview.Obligations {
switch obl.Priority {
case PriorityCritical, ObligationPriority("kritisch"):
criticalObls = append(criticalObls, obl)
case PriorityHigh, ObligationPriority("hoch"):
highObls = append(highObls, obl)
default:
otherObls = append(otherObls, obl)
}
}
// Critical obligations
if len(criticalObls) > 0 {
pdf.SetFont("Helvetica", "B", 11)
pdf.SetTextColor(200, 0, 0)
pdf.CellFormat(0, 8, fmt.Sprintf("Kritische Pflichten (%d)", len(criticalObls)), "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
e.addObligationsList(pdf, criticalObls)
}
// High priority obligations
if len(highObls) > 0 {
pdf.SetFont("Helvetica", "B", 11)
pdf.SetTextColor(200, 100, 0)
pdf.CellFormat(0, 8, fmt.Sprintf("Hohe Prioritaet (%d)", len(highObls)), "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
e.addObligationsList(pdf, highObls)
}
// Other obligations
if len(otherObls) > 0 {
pdf.SetFont("Helvetica", "B", 11)
pdf.CellFormat(0, 8, fmt.Sprintf("Weitere Pflichten (%d)", len(otherObls)), "", 1, "L", false, 0, "")
e.addObligationsList(pdf, otherObls)
}
}
// addObligationsList adds a list of obligations
func (e *PDFExporter) addObligationsList(pdf *gofpdf.Fpdf, obligations []Obligation) {
// Check if we need a new page
if pdf.GetY() > 250 {
pdf.AddPage()
}
pdf.SetFont("Helvetica", "", 9)
for _, obl := range obligations {
// Check if we need a new page
if pdf.GetY() > 270 {
pdf.AddPage()
}
// Obligation ID and title
pdf.SetFont("Helvetica", "B", 9)
pdf.CellFormat(25, 5, obl.ID, "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.CellFormat(0, 5, truncateString(obl.Title, 80), "", 1, "L", false, 0, "")
// Legal basis
if len(obl.LegalBasis) > 0 {
pdf.SetFont("Helvetica", "I", 8)
pdf.CellFormat(25, 4, "", "", 0, "L", false, 0, "")
legalText := ""
for i, lb := range obl.LegalBasis {
if i > 0 {
legalText += ", "
}
legalText += lb.Norm
}
pdf.CellFormat(0, 4, truncateString(legalText, 100), "", 1, "L", false, 0, "")
}
// Deadline
if obl.Deadline != nil {
pdf.SetFont("Helvetica", "", 8)
pdf.CellFormat(25, 4, "", "", 0, "L", false, 0, "")
deadlineText := "Frist: "
if obl.Deadline.Date != nil {
deadlineText += obl.Deadline.Date.Format("02.01.2006")
} else if obl.Deadline.Duration != "" {
deadlineText += obl.Deadline.Duration
} else if obl.Deadline.Interval != "" {
deadlineText += obl.Deadline.Interval
}
pdf.CellFormat(0, 4, deadlineText, "", 1, "L", false, 0, "")
}
// Responsible
pdf.SetFont("Helvetica", "", 8)
pdf.CellFormat(25, 4, "", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 4, fmt.Sprintf("Verantwortlich: %s", obl.Responsible), "", 1, "L", false, 0, "")
pdf.Ln(2)
}
pdf.Ln(5)
}
// addIncidentDeadlines adds the incident deadlines section
func (e *PDFExporter) addIncidentDeadlines(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
// Check if we need a new page
if pdf.GetY() > 220 {
pdf.AddPage()
}
e.addSectionHeader(pdf, "Meldepflichten bei Vorfaellen")
pdf.SetFont("Helvetica", "", 10)
// Group by regulation
byRegulation := make(map[string][]IncidentDeadline)
for _, deadline := range overview.IncidentDeadlines {
byRegulation[deadline.RegulationID] = append(byRegulation[deadline.RegulationID], deadline)
}
for regID, deadlines := range byRegulation {
pdf.SetFont("Helvetica", "B", 10)
regName := regID
for _, reg := range overview.ApplicableRegulations {
if reg.ID == regID {
regName = reg.Name
break
}
}
pdf.CellFormat(0, 7, regName, "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
for _, dl := range deadlines {
pdf.CellFormat(40, 6, dl.Phase+":", "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "B", 9)
pdf.CellFormat(30, 6, dl.Deadline, "", 0, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 9)
pdf.CellFormat(0, 6, "an "+dl.Recipient, "", 1, "L", false, 0, "")
}
pdf.Ln(3)
}
pdf.Ln(5)
}
// addFooter adds the document footer
func (e *PDFExporter) addFooter(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) {
pdf.SetY(-30)
pdf.SetFont("Helvetica", "I", 8)
pdf.SetTextColor(128, 128, 128)
pdf.CellFormat(0, 5, fmt.Sprintf("Generiert am %s mit BreakPilot AI Compliance SDK", time.Now().Format("02.01.2006 15:04")), "", 1, "C", false, 0, "")
pdf.CellFormat(0, 5, "Dieses Dokument ersetzt keine rechtliche Beratung.", "", 1, "C", false, 0, "")
}
// addSectionHeader adds a section header
func (e *PDFExporter) addSectionHeader(pdf *gofpdf.Fpdf, title string) {
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(50, 50, 50)
pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "")
pdf.SetTextColor(0, 0, 0)
// Underline
pdf.SetDrawColor(200, 200, 200)
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
pdf.Ln(5)
}
// truncateString truncates a string to maxLen characters
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
// ExportMarkdown exports the overview as Markdown (for compatibility)
func (e *PDFExporter) ExportMarkdown(overview *ManagementObligationsOverview) (*ExportMemoResponse, error) {
var buf bytes.Buffer
// Title
buf.WriteString(fmt.Sprintf("# Regulatorische Pflichten-Uebersicht\n\n"))
if overview.OrganizationName != "" {
buf.WriteString(fmt.Sprintf("**Organisation:** %s\n\n", overview.OrganizationName))
}
buf.WriteString(fmt.Sprintf("**Stand:** %s\n\n", overview.AssessmentDate.Format("02.01.2006")))
// Executive Summary
buf.WriteString("## Executive Summary\n\n")
summary := overview.ExecutiveSummary
buf.WriteString(fmt.Sprintf("| Metrik | Wert |\n"))
buf.WriteString(fmt.Sprintf("|--------|------|\n"))
buf.WriteString(fmt.Sprintf("| Anwendbare Regulierungen | %d |\n", summary.TotalRegulations))
buf.WriteString(fmt.Sprintf("| Gesamtzahl Pflichten | %d |\n", summary.TotalObligations))
buf.WriteString(fmt.Sprintf("| Kritische Pflichten | %d |\n", summary.CriticalObligations))
buf.WriteString(fmt.Sprintf("| Kommende Fristen (30 Tage) | %d |\n", summary.UpcomingDeadlines))
buf.WriteString(fmt.Sprintf("| Compliance Score | %d%% |\n\n", summary.ComplianceScore))
// Key Risks
if len(summary.KeyRisks) > 0 {
buf.WriteString("### Wesentliche Risiken\n\n")
for _, risk := range summary.KeyRisks {
buf.WriteString(fmt.Sprintf("- %s\n", risk))
}
buf.WriteString("\n")
}
// Recommended Actions
if len(summary.RecommendedActions) > 0 {
buf.WriteString("### Empfohlene Massnahmen\n\n")
for _, action := range summary.RecommendedActions {
buf.WriteString(fmt.Sprintf("- %s\n", action))
}
buf.WriteString("\n")
}
// Applicable Regulations
buf.WriteString("## Anwendbare Regulierungen\n\n")
buf.WriteString("| Regulierung | Klassifizierung | Pflichten | Grund |\n")
buf.WriteString("|-------------|-----------------|-----------|-------|\n")
for _, reg := range overview.ApplicableRegulations {
buf.WriteString(fmt.Sprintf("| %s | %s | %d | %s |\n", reg.Name, reg.Classification, reg.ObligationCount, reg.Reason))
}
buf.WriteString("\n")
// Sanctions Summary
buf.WriteString("## Sanktionsrisiken\n\n")
sanctions := overview.SanctionsSummary
if sanctions.MaxFinancialRisk != "" {
buf.WriteString(fmt.Sprintf("- **Max. Finanzrisiko:** %s\n", sanctions.MaxFinancialRisk))
}
buf.WriteString(fmt.Sprintf("- **Persoenliche Haftung:** %v\n", sanctions.PersonalLiabilityRisk))
buf.WriteString(fmt.Sprintf("- **Strafrechtliche Konsequenzen:** %v\n\n", sanctions.CriminalLiabilityRisk))
if sanctions.Summary != "" {
buf.WriteString(fmt.Sprintf("*%s*\n\n", sanctions.Summary))
}
// Obligations
buf.WriteString("## Pflichten-Uebersicht\n\n")
for _, obl := range overview.Obligations {
buf.WriteString(fmt.Sprintf("### %s - %s\n\n", obl.ID, obl.Title))
buf.WriteString(fmt.Sprintf("**Prioritaet:** %s | **Verantwortlich:** %s\n\n", obl.Priority, obl.Responsible))
if len(obl.LegalBasis) > 0 {
buf.WriteString("**Rechtsgrundlage:** ")
for i, lb := range obl.LegalBasis {
if i > 0 {
buf.WriteString(", ")
}
buf.WriteString(lb.Norm)
}
buf.WriteString("\n\n")
}
buf.WriteString(fmt.Sprintf("%s\n\n", obl.Description))
}
// Incident Deadlines
if len(overview.IncidentDeadlines) > 0 {
buf.WriteString("## Meldepflichten bei Vorfaellen\n\n")
for _, dl := range overview.IncidentDeadlines {
buf.WriteString(fmt.Sprintf("- **%s:** %s an %s\n", dl.Phase, dl.Deadline, dl.Recipient))
}
buf.WriteString("\n")
}
// Footer
buf.WriteString("---\n\n")
buf.WriteString(fmt.Sprintf("*Generiert am %s mit BreakPilot AI Compliance SDK*\n", time.Now().Format("02.01.2006 15:04")))
return &ExportMemoResponse{
Content: buf.String(),
ContentType: "text/markdown",
Filename: fmt.Sprintf("pflichten-uebersicht-%s.md", time.Now().Format("2006-01-02")),
GeneratedAt: time.Now(),
}, nil
}

View File

@@ -0,0 +1,238 @@
package ucca
import (
"encoding/base64"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestPDFExporter_Creation(t *testing.T) {
exporter := NewPDFExporter("de")
if exporter == nil {
t.Error("Expected exporter to be created")
}
// Default language
exporter = NewPDFExporter("")
if exporter.language != "de" {
t.Errorf("Expected default language 'de', got '%s'", exporter.language)
}
}
func TestPDFExporter_ExportMarkdown(t *testing.T) {
overview := createTestOverview()
exporter := NewPDFExporter("de")
response, err := exporter.ExportMarkdown(overview)
if err != nil {
t.Fatalf("Failed to export markdown: %v", err)
}
if response.ContentType != "text/markdown" {
t.Errorf("Expected content type 'text/markdown', got '%s'", response.ContentType)
}
if !strings.HasSuffix(response.Filename, ".md") {
t.Errorf("Expected filename to end with .md, got '%s'", response.Filename)
}
// Check content contains expected sections
if !strings.Contains(response.Content, "# Regulatorische Pflichten-Uebersicht") {
t.Error("Expected markdown to contain title")
}
if !strings.Contains(response.Content, "Executive Summary") {
t.Error("Expected markdown to contain executive summary")
}
if !strings.Contains(response.Content, "Anwendbare Regulierungen") {
t.Error("Expected markdown to contain regulations section")
}
}
func TestPDFExporter_ExportPDF(t *testing.T) {
overview := createTestOverview()
exporter := NewPDFExporter("de")
response, err := exporter.ExportManagementMemo(overview)
if err != nil {
t.Fatalf("Failed to export PDF: %v", err)
}
if response.ContentType != "application/pdf" {
t.Errorf("Expected content type 'application/pdf', got '%s'", response.ContentType)
}
if !strings.HasSuffix(response.Filename, ".pdf") {
t.Errorf("Expected filename to end with .pdf, got '%s'", response.Filename)
}
// Check that content is valid base64
decoded, err := base64.StdEncoding.DecodeString(response.Content)
if err != nil {
t.Fatalf("Failed to decode base64 content: %v", err)
}
// Check PDF magic bytes
if len(decoded) < 4 || string(decoded[:4]) != "%PDF" {
t.Error("Expected content to start with PDF magic bytes")
}
}
func TestPDFExporter_ExportEmptyOverview(t *testing.T) {
overview := &ManagementObligationsOverview{
ID: uuid.New(),
AssessmentDate: time.Now(),
ApplicableRegulations: []ApplicableRegulation{},
Obligations: []Obligation{},
RequiredControls: []ObligationControl{},
IncidentDeadlines: []IncidentDeadline{},
ExecutiveSummary: ExecutiveSummary{
TotalRegulations: 0,
TotalObligations: 0,
ComplianceScore: 100,
KeyRisks: []string{},
RecommendedActions: []string{},
},
SanctionsSummary: SanctionsSummary{
Summary: "Keine Sanktionen identifiziert.",
},
}
exporter := NewPDFExporter("de")
// Test Markdown
mdResponse, err := exporter.ExportMarkdown(overview)
if err != nil {
t.Fatalf("Failed to export empty overview as markdown: %v", err)
}
if mdResponse.Content == "" {
t.Error("Expected non-empty markdown content")
}
// Test PDF
pdfResponse, err := exporter.ExportManagementMemo(overview)
if err != nil {
t.Fatalf("Failed to export empty overview as PDF: %v", err)
}
if pdfResponse.Content == "" {
t.Error("Expected non-empty PDF content")
}
}
func TestPDFExporter_WithIncidentDeadlines(t *testing.T) {
overview := createTestOverview()
overview.IncidentDeadlines = []IncidentDeadline{
{
RegulationID: "nis2",
Phase: "Erstmeldung",
Deadline: "24 Stunden",
Recipient: "BSI",
Content: "Unverzuegliche Meldung erheblicher Sicherheitsvorfaelle",
},
{
RegulationID: "dsgvo",
Phase: "Meldung an Aufsichtsbehoerde",
Deadline: "72 Stunden",
Recipient: "Zustaendige Aufsichtsbehoerde",
Content: "Meldung bei Datenschutzverletzung",
},
}
exporter := NewPDFExporter("de")
// Test that incident deadlines are included
mdResponse, err := exporter.ExportMarkdown(overview)
if err != nil {
t.Fatalf("Failed to export: %v", err)
}
if !strings.Contains(mdResponse.Content, "24 Stunden") {
t.Error("Expected markdown to contain 24 hour deadline")
}
if !strings.Contains(mdResponse.Content, "72 Stunden") {
t.Error("Expected markdown to contain 72 hour deadline")
}
}
func createTestOverview() *ManagementObligationsOverview {
deadline := time.Date(2025, 1, 17, 0, 0, 0, 0, time.UTC)
return &ManagementObligationsOverview{
ID: uuid.New(),
TenantID: uuid.New(),
OrganizationName: "Test GmbH",
AssessmentDate: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ApplicableRegulations: []ApplicableRegulation{
{
ID: "nis2",
Name: "NIS2-Richtlinie",
Classification: "wichtige_einrichtung",
Reason: "Anbieter digitaler Dienste",
ObligationCount: 5,
ControlCount: 3,
},
{
ID: "dsgvo",
Name: "DSGVO",
Classification: "controller",
Reason: "Verarbeitung personenbezogener Daten",
ObligationCount: 4,
ControlCount: 2,
},
},
Obligations: []Obligation{
{
ID: "NIS2-OBL-001",
RegulationID: "nis2",
Title: "BSI-Registrierung",
Description: "Registrierung beim BSI",
LegalBasis: []LegalReference{{Norm: "§ 33 BSIG-E"}},
Category: CategoryMeldepflicht,
Responsible: RoleManagement,
Deadline: &Deadline{Type: DeadlineAbsolute, Date: &deadline},
Sanctions: &SanctionInfo{MaxFine: "500.000 EUR"},
Priority: PriorityCritical,
},
{
ID: "DSGVO-OBL-001",
RegulationID: "dsgvo",
Title: "Verarbeitungsverzeichnis",
Description: "Fuehrung eines VVT",
LegalBasis: []LegalReference{{Norm: "Art. 30 DSGVO"}},
Category: CategoryGovernance,
Responsible: RoleDSB,
Priority: PriorityHigh,
},
},
ExecutiveSummary: ExecutiveSummary{
TotalRegulations: 2,
TotalObligations: 9,
CriticalObligations: 1,
UpcomingDeadlines: 2,
OverdueObligations: 0,
KeyRisks: []string{
"BSI-Registrierung faellig",
"Persoenliche Haftung moeglich",
},
RecommendedActions: []string{
"BSI-Registrierung durchfuehren",
"ISMS aufbauen",
},
ComplianceScore: 75,
},
SanctionsSummary: SanctionsSummary{
MaxFinancialRisk: "10 Mio. EUR oder 2% Jahresumsatz",
PersonalLiabilityRisk: true,
CriminalLiabilityRisk: false,
AffectedRegulations: []string{"nis2", "dsgvo"},
Summary: "Hohe Bussgelder moeglich. Persoenliche Haftung der Geschaeftsfuehrung bei Verstoessen.",
},
}
}

View File

@@ -0,0 +1,882 @@
package ucca
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ============================================================================
// YAML-based Policy Engine
// ============================================================================
//
// This engine evaluates use-case intakes against YAML-defined rules.
// Key design principles:
// - Deterministic: No LLM involvement in rule evaluation
// - Transparent: Rules are auditable YAML
// - Composable: Each field carries its own legal metadata
// - Solution-oriented: Problems include suggested solutions
//
// ============================================================================
// DefaultPolicyPath is the default location for the policy file
var DefaultPolicyPath = "policies/ucca_policy_v1.yaml"
// PolicyConfig represents the full YAML policy structure
type PolicyConfig struct {
Policy PolicyMetadata `yaml:"policy"`
Thresholds Thresholds `yaml:"thresholds"`
Patterns map[string]PatternDef `yaml:"patterns"`
Controls map[string]ControlDef `yaml:"controls"`
Rules []RuleDef `yaml:"rules"`
ProblemSolutions []ProblemSolutionDef `yaml:"problem_solutions"`
EscalationTriggers []EscalationTriggerDef `yaml:"escalation_triggers"`
}
// PolicyMetadata contains policy header info
type PolicyMetadata struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Jurisdiction string `yaml:"jurisdiction"`
Basis []string `yaml:"basis"`
DefaultFeasibility string `yaml:"default_feasibility"`
DefaultRiskScore int `yaml:"default_risk_score"`
}
// Thresholds for risk scoring and escalation
type Thresholds struct {
Risk RiskThresholds `yaml:"risk"`
Escalation []string `yaml:"escalation"`
}
// RiskThresholds defines risk level boundaries
type RiskThresholds struct {
Minimal int `yaml:"minimal"`
Low int `yaml:"low"`
Medium int `yaml:"medium"`
High int `yaml:"high"`
Unacceptable int `yaml:"unacceptable"`
}
// PatternDef represents an architecture pattern from YAML
type PatternDef struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Benefit string `yaml:"benefit"`
Effort string `yaml:"effort"`
RiskReduction int `yaml:"risk_reduction"`
}
// ControlDef represents a required control from YAML
type ControlDef struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Description string `yaml:"description"`
GDPRRef string `yaml:"gdpr_ref"`
Effort string `yaml:"effort"`
}
// RuleDef represents a single rule from YAML
type RuleDef struct {
ID string `yaml:"id"`
Category string `yaml:"category"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Condition ConditionDef `yaml:"condition"`
Effect EffectDef `yaml:"effect"`
Severity string `yaml:"severity"`
GDPRRef string `yaml:"gdpr_ref"`
Rationale string `yaml:"rationale"`
}
// ConditionDef represents a rule condition (supports field checks and compositions)
type ConditionDef struct {
// Simple field check
Field string `yaml:"field,omitempty"`
Operator string `yaml:"operator,omitempty"`
Value interface{} `yaml:"value,omitempty"`
// Composite conditions
AllOf []ConditionDef `yaml:"all_of,omitempty"`
AnyOf []ConditionDef `yaml:"any_of,omitempty"`
// Aggregate conditions (evaluated after all rules)
Aggregate string `yaml:"aggregate,omitempty"`
}
// EffectDef represents the effect when a rule triggers
type EffectDef struct {
RiskAdd int `yaml:"risk_add,omitempty"`
Feasibility string `yaml:"feasibility,omitempty"`
ControlsAdd []string `yaml:"controls_add,omitempty"`
SuggestedPatterns []string `yaml:"suggested_patterns,omitempty"`
Escalation bool `yaml:"escalation,omitempty"`
Art22Risk bool `yaml:"art22_risk,omitempty"`
TrainingAllowed bool `yaml:"training_allowed,omitempty"`
LegalBasis string `yaml:"legal_basis,omitempty"`
}
// ProblemSolutionDef maps problems to solutions
type ProblemSolutionDef struct {
ProblemID string `yaml:"problem_id"`
Title string `yaml:"title"`
Triggers []ProblemTriggerDef `yaml:"triggers"`
Solutions []SolutionDef `yaml:"solutions"`
}
// ProblemTriggerDef defines when a problem is triggered
type ProblemTriggerDef struct {
Rule string `yaml:"rule"`
WithoutControl string `yaml:"without_control,omitempty"`
}
// SolutionDef represents a potential solution
type SolutionDef struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Pattern string `yaml:"pattern,omitempty"`
Control string `yaml:"control,omitempty"`
RemovesProblem bool `yaml:"removes_problem"`
TeamQuestion string `yaml:"team_question"`
}
// EscalationTriggerDef defines when to escalate to DSB
type EscalationTriggerDef struct {
Condition string `yaml:"condition"`
Reason string `yaml:"reason"`
}
// ============================================================================
// Policy Engine Implementation
// ============================================================================
// PolicyEngine evaluates intakes against YAML-defined rules
type PolicyEngine struct {
config *PolicyConfig
}
// NewPolicyEngine creates a new policy engine, loading from the default path
// It searches for the policy file in common locations
func NewPolicyEngine() (*PolicyEngine, error) {
// Try multiple locations to find the policy file
searchPaths := []string{
DefaultPolicyPath,
filepath.Join(".", "policies", "ucca_policy_v1.yaml"),
filepath.Join("..", "policies", "ucca_policy_v1.yaml"),
filepath.Join("..", "..", "policies", "ucca_policy_v1.yaml"),
"/app/policies/ucca_policy_v1.yaml", // Docker container path
}
var data []byte
var err error
for _, path := range searchPaths {
data, err = os.ReadFile(path)
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("failed to load policy from any known location: %w", err)
}
var config PolicyConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse policy YAML: %w", err)
}
return &PolicyEngine{config: &config}, nil
}
// NewPolicyEngineFromPath loads policy from a specific file path
func NewPolicyEngineFromPath(path string) (*PolicyEngine, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read policy file: %w", err)
}
var config PolicyConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse policy YAML: %w", err)
}
return &PolicyEngine{config: &config}, nil
}
// GetPolicyVersion returns the policy version
func (e *PolicyEngine) GetPolicyVersion() string {
return e.config.Policy.Version
}
// Evaluate runs all YAML rules against the intake
func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult {
result := &AssessmentResult{
Feasibility: FeasibilityYES,
RiskLevel: RiskLevelMINIMAL,
Complexity: ComplexityLOW,
RiskScore: 0,
TriggeredRules: []TriggeredRule{},
RequiredControls: []RequiredControl{},
RecommendedArchitecture: []PatternRecommendation{},
ForbiddenPatterns: []ForbiddenPattern{},
ExampleMatches: []ExampleMatch{},
DSFARecommended: false,
Art22Risk: false,
TrainingAllowed: TrainingYES,
}
// Track state for aggregation
hasBlock := false
hasWarn := false
controlSet := make(map[string]bool)
patternPriority := make(map[string]int)
triggeredRuleIDs := make(map[string]bool)
needsEscalation := false
// Evaluate each non-aggregate rule
priority := 1
for _, rule := range e.config.Rules {
// Skip aggregate rules (evaluated later)
if rule.Condition.Aggregate != "" {
continue
}
if e.evaluateCondition(&rule.Condition, intake) {
triggeredRuleIDs[rule.ID] = true
// Create triggered rule record
triggered := TriggeredRule{
Code: rule.ID,
Category: rule.Category,
Title: rule.Title,
Description: rule.Description,
Severity: parseSeverity(rule.Severity),
ScoreDelta: rule.Effect.RiskAdd,
GDPRRef: rule.GDPRRef,
Rationale: rule.Rationale,
}
result.TriggeredRules = append(result.TriggeredRules, triggered)
// Apply effects
result.RiskScore += rule.Effect.RiskAdd
// Track severity
switch parseSeverity(rule.Severity) {
case SeverityBLOCK:
hasBlock = true
case SeverityWARN:
hasWarn = true
}
// Override feasibility if specified
if rule.Effect.Feasibility != "" {
switch rule.Effect.Feasibility {
case "NO":
result.Feasibility = FeasibilityNO
case "CONDITIONAL":
if result.Feasibility != FeasibilityNO {
result.Feasibility = FeasibilityCONDITIONAL
}
case "YES":
// Only set YES if not already NO or CONDITIONAL
if result.Feasibility != FeasibilityNO && result.Feasibility != FeasibilityCONDITIONAL {
result.Feasibility = FeasibilityYES
}
}
}
// Collect controls
for _, ctrlID := range rule.Effect.ControlsAdd {
if !controlSet[ctrlID] {
controlSet[ctrlID] = true
if ctrl, ok := e.config.Controls[ctrlID]; ok {
result.RequiredControls = append(result.RequiredControls, RequiredControl{
ID: ctrl.ID,
Title: ctrl.Title,
Description: ctrl.Description,
Severity: parseSeverity(rule.Severity),
Category: categorizeControl(ctrl.ID),
GDPRRef: ctrl.GDPRRef,
})
}
}
}
// Collect patterns
for _, patternID := range rule.Effect.SuggestedPatterns {
if _, exists := patternPriority[patternID]; !exists {
patternPriority[patternID] = priority
priority++
}
}
// Track special flags
if rule.Effect.Escalation {
needsEscalation = true
}
if rule.Effect.Art22Risk {
result.Art22Risk = true
}
}
}
// Apply aggregation rules
if hasBlock {
result.Feasibility = FeasibilityNO
} else if hasWarn && result.Feasibility != FeasibilityNO {
result.Feasibility = FeasibilityCONDITIONAL
}
// Determine risk level from thresholds
result.RiskLevel = e.calculateRiskLevel(result.RiskScore)
// Determine complexity
result.Complexity = e.calculateComplexity(result)
// Check if DSFA is recommended
result.DSFARecommended = e.shouldRecommendDSFA(intake, result)
// Determine training allowed status
result.TrainingAllowed = e.determineTrainingAllowed(intake)
// Add recommended patterns (sorted by priority)
result.RecommendedArchitecture = e.buildPatternRecommendations(patternPriority)
// Match didactic examples
result.ExampleMatches = MatchExamples(intake)
// Generate summaries
result.Summary = e.generateSummary(result, intake)
result.Recommendation = e.generateRecommendation(result, intake)
if result.Feasibility == FeasibilityNO {
result.AlternativeApproach = e.generateAlternative(result, intake, triggeredRuleIDs)
}
// Note: needsEscalation could be used to flag the assessment for DSB review
_ = needsEscalation
return result
}
// evaluateCondition recursively evaluates a condition against the intake
func (e *PolicyEngine) evaluateCondition(cond *ConditionDef, intake *UseCaseIntake) bool {
// Handle composite all_of
if len(cond.AllOf) > 0 {
for _, subCond := range cond.AllOf {
if !e.evaluateCondition(&subCond, intake) {
return false
}
}
return true
}
// Handle composite any_of
if len(cond.AnyOf) > 0 {
for _, subCond := range cond.AnyOf {
if e.evaluateCondition(&subCond, intake) {
return true
}
}
return false
}
// Handle simple field condition
if cond.Field != "" {
return e.evaluateFieldCondition(cond.Field, cond.Operator, cond.Value, intake)
}
return false
}
// evaluateFieldCondition evaluates a single field comparison
func (e *PolicyEngine) evaluateFieldCondition(field, operator string, value interface{}, intake *UseCaseIntake) bool {
// Get the field value from intake
fieldValue := e.getFieldValue(field, intake)
if fieldValue == nil {
return false
}
switch operator {
case "equals":
return e.compareEquals(fieldValue, value)
case "not_equals":
return !e.compareEquals(fieldValue, value)
case "in":
return e.compareIn(fieldValue, value)
case "contains":
return e.compareContains(fieldValue, value)
default:
return false
}
}
// getFieldValue extracts a field value from the intake using dot notation
func (e *PolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interface{} {
parts := strings.Split(field, ".")
if len(parts) == 0 {
return nil
}
switch parts[0] {
case "data_types":
if len(parts) < 2 {
return nil
}
return e.getDataTypeValue(parts[1], intake)
case "purpose":
if len(parts) < 2 {
return nil
}
return e.getPurposeValue(parts[1], intake)
case "automation":
return string(intake.Automation)
case "outputs":
if len(parts) < 2 {
return nil
}
return e.getOutputsValue(parts[1], intake)
case "hosting":
if len(parts) < 2 {
return nil
}
return e.getHostingValue(parts[1], intake)
case "model_usage":
if len(parts) < 2 {
return nil
}
return e.getModelUsageValue(parts[1], intake)
case "domain":
return string(intake.Domain)
case "retention":
if len(parts) < 2 {
return nil
}
return e.getRetentionValue(parts[1], intake)
}
return nil
}
func (e *PolicyEngine) getDataTypeValue(field string, intake *UseCaseIntake) interface{} {
switch field {
case "personal_data":
return intake.DataTypes.PersonalData
case "article_9_data":
return intake.DataTypes.Article9Data
case "minor_data":
return intake.DataTypes.MinorData
case "license_plates":
return intake.DataTypes.LicensePlates
case "images":
return intake.DataTypes.Images
case "audio":
return intake.DataTypes.Audio
case "location_data":
return intake.DataTypes.LocationData
case "biometric_data":
return intake.DataTypes.BiometricData
case "financial_data":
return intake.DataTypes.FinancialData
case "employee_data":
return intake.DataTypes.EmployeeData
case "customer_data":
return intake.DataTypes.CustomerData
case "public_data":
return intake.DataTypes.PublicData
}
return nil
}
func (e *PolicyEngine) getPurposeValue(field string, intake *UseCaseIntake) interface{} {
switch field {
case "customer_support":
return intake.Purpose.CustomerSupport
case "marketing":
return intake.Purpose.Marketing
case "analytics":
return intake.Purpose.Analytics
case "automation":
return intake.Purpose.Automation
case "evaluation_scoring":
return intake.Purpose.EvaluationScoring
case "decision_making":
return intake.Purpose.DecisionMaking
case "profiling":
return intake.Purpose.Profiling
case "research":
return intake.Purpose.Research
case "internal_tools":
return intake.Purpose.InternalTools
case "public_service":
return intake.Purpose.PublicService
}
return nil
}
func (e *PolicyEngine) getOutputsValue(field string, intake *UseCaseIntake) interface{} {
switch field {
case "recommendations_to_users":
return intake.Outputs.RecommendationsToUsers
case "rankings_or_scores":
return intake.Outputs.RankingsOrScores
case "legal_effects":
return intake.Outputs.LegalEffects
case "access_decisions":
return intake.Outputs.AccessDecisions
case "content_generation":
return intake.Outputs.ContentGeneration
case "data_export":
return intake.Outputs.DataExport
}
return nil
}
func (e *PolicyEngine) getHostingValue(field string, intake *UseCaseIntake) interface{} {
switch field {
case "provider":
return intake.Hosting.Provider
case "region":
return intake.Hosting.Region
case "data_residency":
return intake.Hosting.DataResidency
}
return nil
}
func (e *PolicyEngine) getModelUsageValue(field string, intake *UseCaseIntake) interface{} {
switch field {
case "rag":
return intake.ModelUsage.RAG
case "finetune":
return intake.ModelUsage.Finetune
case "training":
return intake.ModelUsage.Training
case "inference":
return intake.ModelUsage.Inference
}
return nil
}
func (e *PolicyEngine) getRetentionValue(field string, intake *UseCaseIntake) interface{} {
switch field {
case "store_prompts":
return intake.Retention.StorePrompts
case "store_responses":
return intake.Retention.StoreResponses
case "retention_days":
return intake.Retention.RetentionDays
case "anonymize_after_use":
return intake.Retention.AnonymizeAfterUse
}
return nil
}
// compareEquals compares two values for equality
func (e *PolicyEngine) compareEquals(fieldValue, expected interface{}) bool {
// Handle bool comparison
if bv, ok := fieldValue.(bool); ok {
if eb, ok := expected.(bool); ok {
return bv == eb
}
}
// Handle string comparison
if sv, ok := fieldValue.(string); ok {
if es, ok := expected.(string); ok {
return sv == es
}
}
// Handle int comparison
if iv, ok := fieldValue.(int); ok {
switch ev := expected.(type) {
case int:
return iv == ev
case float64:
return iv == int(ev)
}
}
return false
}
// compareIn checks if fieldValue is in a list of expected values
func (e *PolicyEngine) compareIn(fieldValue, expected interface{}) bool {
list, ok := expected.([]interface{})
if !ok {
return false
}
sv, ok := fieldValue.(string)
if !ok {
return false
}
for _, item := range list {
if is, ok := item.(string); ok && is == sv {
return true
}
}
return false
}
// compareContains checks if a string contains a substring
func (e *PolicyEngine) compareContains(fieldValue, expected interface{}) bool {
sv, ok := fieldValue.(string)
if !ok {
return false
}
es, ok := expected.(string)
if !ok {
return false
}
return strings.Contains(strings.ToLower(sv), strings.ToLower(es))
}
// calculateRiskLevel determines risk level from score
func (e *PolicyEngine) calculateRiskLevel(score int) RiskLevel {
t := e.config.Thresholds.Risk
if score >= t.Unacceptable {
return RiskLevelUNACCEPTABLE
}
if score >= t.High {
return RiskLevelHIGH
}
if score >= t.Medium {
return RiskLevelMEDIUM
}
if score >= t.Low {
return RiskLevelLOW
}
return RiskLevelMINIMAL
}
// calculateComplexity determines implementation complexity
func (e *PolicyEngine) calculateComplexity(result *AssessmentResult) Complexity {
controlCount := len(result.RequiredControls)
if controlCount >= 5 || result.RiskScore >= 50 {
return ComplexityHIGH
}
if controlCount >= 3 || result.RiskScore >= 25 {
return ComplexityMEDIUM
}
return ComplexityLOW
}
// shouldRecommendDSFA checks if a DSFA is recommended
func (e *PolicyEngine) shouldRecommendDSFA(intake *UseCaseIntake, result *AssessmentResult) bool {
if result.RiskLevel == RiskLevelHIGH || result.RiskLevel == RiskLevelUNACCEPTABLE {
return true
}
if intake.DataTypes.Article9Data || intake.DataTypes.BiometricData {
return true
}
if intake.Purpose.Profiling && intake.DataTypes.PersonalData {
return true
}
// Check if C_DSFA control is required
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "C_DSFA" {
return true
}
}
return false
}
// determineTrainingAllowed checks training permission
func (e *PolicyEngine) determineTrainingAllowed(intake *UseCaseIntake) TrainingAllowed {
if intake.ModelUsage.Training && intake.DataTypes.PersonalData {
return TrainingNO
}
if intake.ModelUsage.Finetune && intake.DataTypes.PersonalData {
return TrainingCONDITIONAL
}
if intake.DataTypes.MinorData && (intake.ModelUsage.Training || intake.ModelUsage.Finetune) {
return TrainingNO
}
return TrainingYES
}
// buildPatternRecommendations creates sorted pattern recommendations
func (e *PolicyEngine) buildPatternRecommendations(patternPriority map[string]int) []PatternRecommendation {
type priorityPair struct {
id string
priority int
}
pairs := make([]priorityPair, 0, len(patternPriority))
for id, p := range patternPriority {
pairs = append(pairs, priorityPair{id, p})
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].priority < pairs[j].priority
})
recommendations := make([]PatternRecommendation, 0, len(pairs))
for _, p := range pairs {
if pattern, ok := e.config.Patterns[p.id]; ok {
recommendations = append(recommendations, PatternRecommendation{
PatternID: pattern.ID,
Title: pattern.Title,
Description: pattern.Description,
Rationale: pattern.Benefit,
Priority: p.priority,
})
}
}
return recommendations
}
// generateSummary creates a human-readable summary
func (e *PolicyEngine) generateSummary(result *AssessmentResult, intake *UseCaseIntake) string {
var parts []string
switch result.Feasibility {
case FeasibilityYES:
parts = append(parts, "Der Use Case ist aus DSGVO-Sicht grundsätzlich umsetzbar.")
case FeasibilityCONDITIONAL:
parts = append(parts, "Der Use Case ist unter Auflagen umsetzbar.")
case FeasibilityNO:
parts = append(parts, "Der Use Case ist in der aktuellen Form nicht DSGVO-konform umsetzbar.")
}
blockCount := 0
warnCount := 0
for _, r := range result.TriggeredRules {
if r.Severity == SeverityBLOCK {
blockCount++
} else if r.Severity == SeverityWARN {
warnCount++
}
}
if blockCount > 0 {
parts = append(parts, fmt.Sprintf("%d kritische Regelverletzung(en) identifiziert.", blockCount))
}
if warnCount > 0 {
parts = append(parts, fmt.Sprintf("%d Warnungen erfordern Aufmerksamkeit.", warnCount))
}
if result.DSFARecommended {
parts = append(parts, "Eine Datenschutz-Folgenabschätzung (DSFA) wird empfohlen.")
}
return strings.Join(parts, " ")
}
// generateRecommendation creates actionable recommendations
func (e *PolicyEngine) generateRecommendation(result *AssessmentResult, intake *UseCaseIntake) string {
if result.Feasibility == FeasibilityYES {
return "Fahren Sie mit der Implementierung fort. Beachten Sie die empfohlenen Architektur-Patterns für optimale DSGVO-Konformität."
}
if result.Feasibility == FeasibilityCONDITIONAL {
if len(result.RequiredControls) > 0 {
return fmt.Sprintf("Implementieren Sie die %d erforderlichen Kontrollen vor dem Go-Live. Dokumentieren Sie alle Maßnahmen für den Nachweis der Rechenschaftspflicht (Art. 5 DSGVO).", len(result.RequiredControls))
}
return "Prüfen Sie die ausgelösten Warnungen und implementieren Sie entsprechende Schutzmaßnahmen."
}
return "Der Use Case erfordert grundlegende Änderungen. Prüfen Sie die Lösungsvorschläge."
}
// generateAlternative creates alternative approach suggestions
func (e *PolicyEngine) generateAlternative(result *AssessmentResult, intake *UseCaseIntake, triggeredRules map[string]bool) string {
var suggestions []string
// Find applicable problem-solutions
for _, ps := range e.config.ProblemSolutions {
for _, trigger := range ps.Triggers {
if triggeredRules[trigger.Rule] {
// Check if control is missing (if specified)
if trigger.WithoutControl != "" {
hasControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == trigger.WithoutControl {
hasControl = true
break
}
}
if hasControl {
continue
}
}
// Add first solution as suggestion
if len(ps.Solutions) > 0 {
sol := ps.Solutions[0]
suggestions = append(suggestions, fmt.Sprintf("%s: %s", sol.Title, sol.TeamQuestion))
}
}
}
}
// Fallback suggestions based on intake
if len(suggestions) == 0 {
if intake.ModelUsage.Training && intake.DataTypes.PersonalData {
suggestions = append(suggestions, "Nutzen Sie nur RAG statt Training mit personenbezogenen Daten")
}
if intake.Automation == AutomationFullyAutomated && intake.Outputs.LegalEffects {
suggestions = append(suggestions, "Implementieren Sie Human-in-the-Loop für Entscheidungen mit rechtlichen Auswirkungen")
}
if intake.DataTypes.MinorData && intake.Purpose.EvaluationScoring {
suggestions = append(suggestions, "Verzichten Sie auf automatisches Scoring von Minderjährigen")
}
}
if len(suggestions) == 0 {
return "Überarbeiten Sie den Use Case unter Berücksichtigung der ausgelösten Regeln."
}
return strings.Join(suggestions, " | ")
}
// GetAllRules returns all rules in the policy
func (e *PolicyEngine) GetAllRules() []RuleDef {
return e.config.Rules
}
// GetAllPatterns returns all patterns in the policy
func (e *PolicyEngine) GetAllPatterns() map[string]PatternDef {
return e.config.Patterns
}
// GetAllControls returns all controls in the policy
func (e *PolicyEngine) GetAllControls() map[string]ControlDef {
return e.config.Controls
}
// GetProblemSolutions returns problem-solution mappings
func (e *PolicyEngine) GetProblemSolutions() []ProblemSolutionDef {
return e.config.ProblemSolutions
}
// ============================================================================
// Helper Functions
// ============================================================================
func parseSeverity(s string) Severity {
switch strings.ToUpper(s) {
case "BLOCK":
return SeverityBLOCK
case "WARN":
return SeverityWARN
default:
return SeverityINFO
}
}
func categorizeControl(id string) string {
// Map control IDs to categories
technical := map[string]bool{
"C_ENCRYPTION": true, "C_ACCESS_LOGGING": true,
}
if technical[id] {
return "technical"
}
return "organizational"
}

View File

@@ -0,0 +1,940 @@
package ucca
import (
"os"
"path/filepath"
"testing"
)
// Helper to get the project root for testing
func getProjectRoot(t *testing.T) string {
// Start from the current directory and walk up to find go.mod
dir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatalf("Could not find project root (no go.mod found)")
}
dir = parent
}
}
func TestNewPolicyEngineFromPath(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
if engine.GetPolicyVersion() != "1.0.0" {
t.Errorf("Expected policy version 1.0.0, got %s", engine.GetPolicyVersion())
}
}
func TestPolicyEngine_EvaluateSimpleCase(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Simple RAG chatbot for utilities (low risk)
intake := &UseCaseIntake{
UseCaseText: "Chatbot für Stadtwerke mit FAQ-Suche",
Domain: DomainUtilities,
DataTypes: DataTypes{
PersonalData: false,
PublicData: true,
},
Purpose: Purpose{
CustomerSupport: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{
RAG: true,
Training: false,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.Feasibility != FeasibilityYES {
t.Errorf("Expected feasibility YES, got %s", result.Feasibility)
}
if result.RiskLevel != RiskLevelMINIMAL {
t.Errorf("Expected risk level MINIMAL, got %s", result.RiskLevel)
}
}
func TestPolicyEngine_EvaluateHighRiskCase(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: HR scoring with full automation (should be blocked)
intake := &UseCaseIntake{
UseCaseText: "Automatische Mitarbeiterbewertung",
Domain: DomainHR,
DataTypes: DataTypes{
PersonalData: true,
EmployeeData: true,
},
Purpose: Purpose{
EvaluationScoring: true,
},
Automation: AutomationFullyAutomated,
Outputs: Outputs{
RankingsOrScores: true,
},
ModelUsage: ModelUsage{
Training: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.Feasibility != FeasibilityNO {
t.Errorf("Expected feasibility NO for HR scoring, got %s", result.Feasibility)
}
// Should have at least one BLOCK severity rule triggered
hasBlock := false
for _, rule := range result.TriggeredRules {
if rule.Severity == SeverityBLOCK {
hasBlock = true
break
}
}
if !hasBlock {
t.Error("Expected at least one BLOCK severity rule for HR scoring")
}
}
func TestPolicyEngine_EvaluateConditionalCase(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Personal data with marketing (should be conditional)
intake := &UseCaseIntake{
UseCaseText: "Marketing-Personalisierung",
Domain: DomainMarketing,
DataTypes: DataTypes{
PersonalData: true,
},
Purpose: Purpose{
Marketing: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{
RAG: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.Feasibility != FeasibilityCONDITIONAL {
t.Errorf("Expected feasibility CONDITIONAL for marketing with PII, got %s", result.Feasibility)
}
// Should require consent control
hasConsentControl := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "C_EXPLICIT_CONSENT" {
hasConsentControl = true
break
}
}
if !hasConsentControl {
t.Error("Expected C_EXPLICIT_CONSENT control for marketing with PII")
}
}
func TestPolicyEngine_EvaluateArticle9Data(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Healthcare with Art. 9 data
intake := &UseCaseIntake{
UseCaseText: "Patientendaten-Analyse",
Domain: DomainHealthcare,
DataTypes: DataTypes{
PersonalData: true,
Article9Data: true,
},
Purpose: Purpose{
Analytics: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{
RAG: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
// Art. 9 data should trigger DSFA recommendation
if !result.DSFARecommended {
t.Error("Expected DSFA recommended for Art. 9 data")
}
// Should have triggered Art. 9 rule
hasArt9Rule := false
for _, rule := range result.TriggeredRules {
if rule.Code == "R-A002" {
hasArt9Rule = true
break
}
}
if !hasArt9Rule {
t.Error("Expected R-A002 (Art. 9 data) rule to be triggered")
}
}
func TestPolicyEngine_EvaluateLicensePlates(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Parking with license plates
intake := &UseCaseIntake{
UseCaseText: "Parkhaus-Kennzeichenerkennung",
Domain: DomainRealEstate,
DataTypes: DataTypes{
PersonalData: true,
LicensePlates: true,
},
Purpose: Purpose{
Automation: true,
},
Automation: AutomationSemiAutomated,
ModelUsage: ModelUsage{
Inference: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
// Should suggest pixelization pattern
hasPixelization := false
for _, pattern := range result.RecommendedArchitecture {
if pattern.PatternID == "P_PIXELIZATION" {
hasPixelization = true
break
}
}
if !hasPixelization {
t.Error("Expected P_PIXELIZATION pattern to be recommended for license plates")
}
}
func TestPolicyEngine_EvaluateThirdCountryTransfer(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Third country hosting with PII
intake := &UseCaseIntake{
UseCaseText: "US-hosted AI service",
Domain: DomainITServices,
DataTypes: DataTypes{
PersonalData: true,
},
Purpose: Purpose{
InternalTools: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{
RAG: true,
},
Hosting: Hosting{
Region: "third_country",
},
}
result := engine.Evaluate(intake)
// Should require SCC control
hasSCC := false
for _, ctrl := range result.RequiredControls {
if ctrl.ID == "C_SCC" {
hasSCC = true
break
}
}
if !hasSCC {
t.Error("Expected C_SCC control for third country transfer with PII")
}
}
func TestPolicyEngine_GetAllRules(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
rules := engine.GetAllRules()
if len(rules) == 0 {
t.Error("Expected at least some rules")
}
// Check that rules have required fields
for _, rule := range rules {
if rule.ID == "" {
t.Error("Found rule without ID")
}
if rule.Category == "" {
t.Error("Found rule without category")
}
if rule.Title == "" {
t.Error("Found rule without title")
}
}
}
func TestPolicyEngine_GetAllPatterns(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
patterns := engine.GetAllPatterns()
if len(patterns) == 0 {
t.Error("Expected at least some patterns")
}
// Verify expected patterns exist
expectedPatterns := []string{"P_RAG_ONLY", "P_PRE_ANON", "P_PIXELIZATION", "P_HITL_ENFORCED"}
for _, expected := range expectedPatterns {
if _, exists := patterns[expected]; !exists {
t.Errorf("Expected pattern %s to exist", expected)
}
}
}
func TestPolicyEngine_GetAllControls(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
controls := engine.GetAllControls()
if len(controls) == 0 {
t.Error("Expected at least some controls")
}
// Verify expected controls exist
expectedControls := []string{"C_EXPLICIT_CONSENT", "C_DSFA", "C_ENCRYPTION", "C_SCC"}
for _, expected := range expectedControls {
if _, exists := controls[expected]; !exists {
t.Errorf("Expected control %s to exist", expected)
}
}
}
func TestPolicyEngine_TrainingWithMinorData(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Training with minor data (should be blocked)
intake := &UseCaseIntake{
UseCaseText: "KI-Training mit Schülerdaten",
Domain: DomainEducation,
DataTypes: DataTypes{
PersonalData: true,
MinorData: true,
},
Purpose: Purpose{
Research: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{
Training: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.TrainingAllowed != TrainingNO {
t.Errorf("Expected training NOT allowed for minor data, got %s", result.TrainingAllowed)
}
}
func TestPolicyEngine_CompositeConditions(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Test case: Fully automated with legal effects (R-C004 uses all_of)
intake := &UseCaseIntake{
UseCaseText: "Automatische Vertragsgenehmigung",
Domain: DomainLegal,
DataTypes: DataTypes{
PersonalData: true,
},
Purpose: Purpose{
DecisionMaking: true,
},
Automation: AutomationFullyAutomated,
Outputs: Outputs{
LegalEffects: true,
},
ModelUsage: ModelUsage{
Inference: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.Feasibility != FeasibilityNO {
t.Errorf("Expected NO feasibility for fully automated legal decisions, got %s", result.Feasibility)
}
// Check that R-C004 was triggered
hasC004 := false
for _, rule := range result.TriggeredRules {
if rule.Code == "R-C004" {
hasC004 = true
break
}
}
if !hasC004 {
t.Error("Expected R-C004 (automated legal effects) to be triggered")
}
}
// ============================================================================
// Determinism Tests - Ensure consistent results
// ============================================================================
func TestPolicyEngine_Determinism(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
intake := &UseCaseIntake{
UseCaseText: "Test Case für Determinismus",
Domain: DomainEducation,
DataTypes: DataTypes{
PersonalData: true,
MinorData: true,
},
Purpose: Purpose{
EvaluationScoring: true,
},
Automation: AutomationFullyAutomated,
Outputs: Outputs{
RankingsOrScores: true,
},
ModelUsage: ModelUsage{
Training: true,
},
Hosting: Hosting{
Region: "eu",
},
}
// Run evaluation 10 times and ensure identical results
firstResult := engine.Evaluate(intake)
for i := 0; i < 10; i++ {
result := engine.Evaluate(intake)
if result.Feasibility != firstResult.Feasibility {
t.Errorf("Run %d: Feasibility mismatch: %s vs %s", i, result.Feasibility, firstResult.Feasibility)
}
if result.RiskScore != firstResult.RiskScore {
t.Errorf("Run %d: RiskScore mismatch: %d vs %d", i, result.RiskScore, firstResult.RiskScore)
}
if result.RiskLevel != firstResult.RiskLevel {
t.Errorf("Run %d: RiskLevel mismatch: %s vs %s", i, result.RiskLevel, firstResult.RiskLevel)
}
if len(result.TriggeredRules) != len(firstResult.TriggeredRules) {
t.Errorf("Run %d: TriggeredRules count mismatch: %d vs %d", i, len(result.TriggeredRules), len(firstResult.TriggeredRules))
}
}
}
// ============================================================================
// Education Domain Specific Tests
// ============================================================================
func TestPolicyEngine_EducationScoring_AlwaysBlocked(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Education + Scoring + Fully Automated = BLOCK (R-F001)
intake := &UseCaseIntake{
UseCaseText: "Automatische Schülerbewertung",
Domain: DomainEducation,
DataTypes: DataTypes{
PersonalData: true,
MinorData: true,
},
Purpose: Purpose{
EvaluationScoring: true,
},
Automation: AutomationFullyAutomated,
Outputs: Outputs{
RankingsOrScores: true,
},
ModelUsage: ModelUsage{
Inference: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.Feasibility != FeasibilityNO {
t.Errorf("Expected NO for education automated scoring, got %s", result.Feasibility)
}
// Should have Art. 22 risk flagged
if !result.Art22Risk {
t.Error("Expected Art. 22 risk for automated individual decisions in education")
}
}
// ============================================================================
// RAG-Only Use Cases (Low Risk)
// ============================================================================
func TestPolicyEngine_RAGOnly_LowRisk(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
intake := &UseCaseIntake{
UseCaseText: "FAQ-Suche mit öffentlichen Dokumenten",
Domain: DomainUtilities,
DataTypes: DataTypes{
PublicData: true,
},
Purpose: Purpose{
CustomerSupport: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{
RAG: true,
},
Hosting: Hosting{
Region: "eu",
},
}
result := engine.Evaluate(intake)
if result.Feasibility != FeasibilityYES {
t.Errorf("Expected YES for RAG-only public data, got %s", result.Feasibility)
}
if result.RiskLevel != RiskLevelMINIMAL {
t.Errorf("Expected MINIMAL risk for RAG-only, got %s", result.RiskLevel)
}
// Should recommend P_RAG_ONLY pattern
hasRAGPattern := false
for _, pattern := range result.RecommendedArchitecture {
if pattern.PatternID == "P_RAG_ONLY" {
hasRAGPattern = true
break
}
}
if !hasRAGPattern {
t.Error("Expected P_RAG_ONLY pattern recommendation")
}
}
// ============================================================================
// Risk Score Calculation Tests
// ============================================================================
func TestPolicyEngine_RiskScoreCalculation(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
tests := []struct {
name string
intake *UseCaseIntake
minScore int
maxScore int
expectedRiskLevel RiskLevel
}{
{
name: "Public data only → minimal risk",
intake: &UseCaseIntake{
Domain: DomainUtilities,
DataTypes: DataTypes{
PublicData: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{RAG: true},
Hosting: Hosting{Region: "eu"},
},
minScore: 0,
maxScore: 20,
expectedRiskLevel: RiskLevelMINIMAL,
},
{
name: "Personal data → low risk",
intake: &UseCaseIntake{
Domain: DomainITServices,
DataTypes: DataTypes{
PersonalData: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{RAG: true},
Hosting: Hosting{Region: "eu"},
},
minScore: 5,
maxScore: 40,
expectedRiskLevel: RiskLevelLOW,
},
{
name: "Art. 9 data → medium risk",
intake: &UseCaseIntake{
Domain: DomainHealthcare,
DataTypes: DataTypes{
PersonalData: true,
Article9Data: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{RAG: true},
Hosting: Hosting{Region: "eu"},
},
minScore: 20,
maxScore: 60,
expectedRiskLevel: RiskLevelMEDIUM,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := engine.Evaluate(tt.intake)
if result.RiskScore < tt.minScore || result.RiskScore > tt.maxScore {
t.Errorf("RiskScore %d outside expected range [%d, %d]", result.RiskScore, tt.minScore, tt.maxScore)
}
})
}
}
// ============================================================================
// Training Allowed Tests
// ============================================================================
func TestPolicyEngine_TrainingAllowed_Scenarios(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
tests := []struct {
name string
intake *UseCaseIntake
expectedAllowed TrainingAllowed
}{
{
name: "Public data training → allowed",
intake: &UseCaseIntake{
Domain: DomainUtilities,
DataTypes: DataTypes{
PublicData: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{Training: true},
Hosting: Hosting{Region: "eu"},
},
expectedAllowed: TrainingYES,
},
{
name: "Minor data training → not allowed",
intake: &UseCaseIntake{
Domain: DomainEducation,
DataTypes: DataTypes{
PersonalData: true,
MinorData: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{Training: true},
Hosting: Hosting{Region: "eu"},
},
expectedAllowed: TrainingNO,
},
{
name: "Art. 9 data training → not allowed",
intake: &UseCaseIntake{
Domain: DomainHealthcare,
DataTypes: DataTypes{
PersonalData: true,
Article9Data: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{Training: true},
Hosting: Hosting{Region: "eu"},
},
expectedAllowed: TrainingNO,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := engine.Evaluate(tt.intake)
if result.TrainingAllowed != tt.expectedAllowed {
t.Errorf("Expected TrainingAllowed=%s, got %s", tt.expectedAllowed, result.TrainingAllowed)
}
})
}
}
// ============================================================================
// DSFA Recommendation Tests
// ============================================================================
func TestPolicyEngine_DSFARecommendation(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
tests := []struct {
name string
intake *UseCaseIntake
expectDSFA bool
expectArt22 bool
}{
{
name: "Art. 9 data → DSFA required",
intake: &UseCaseIntake{
Domain: DomainHealthcare,
DataTypes: DataTypes{
PersonalData: true,
Article9Data: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{RAG: true},
Hosting: Hosting{Region: "eu"},
},
expectDSFA: true,
expectArt22: false,
},
{
name: "Systematic evaluation → DSFA + Art. 22",
intake: &UseCaseIntake{
Domain: DomainHR,
DataTypes: DataTypes{
PersonalData: true,
EmployeeData: true,
},
Purpose: Purpose{
EvaluationScoring: true,
},
Automation: AutomationFullyAutomated,
Outputs: Outputs{
RankingsOrScores: true,
},
ModelUsage: ModelUsage{Inference: true},
Hosting: Hosting{Region: "eu"},
},
expectDSFA: true,
expectArt22: true,
},
{
name: "Public data RAG → no DSFA",
intake: &UseCaseIntake{
Domain: DomainUtilities,
DataTypes: DataTypes{
PublicData: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{RAG: true},
Hosting: Hosting{Region: "eu"},
},
expectDSFA: false,
expectArt22: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := engine.Evaluate(tt.intake)
if result.DSFARecommended != tt.expectDSFA {
t.Errorf("Expected DSFARecommended=%v, got %v", tt.expectDSFA, result.DSFARecommended)
}
if result.Art22Risk != tt.expectArt22 {
t.Errorf("Expected Art22Risk=%v, got %v", tt.expectArt22, result.Art22Risk)
}
})
}
}
// ============================================================================
// Required Controls Tests
// ============================================================================
func TestPolicyEngine_RequiredControls(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
// Third country with PII should require SCC
intake := &UseCaseIntake{
Domain: DomainITServices,
DataTypes: DataTypes{
PersonalData: true,
},
Automation: AutomationAssistive,
ModelUsage: ModelUsage{RAG: true},
Hosting: Hosting{
Region: "third_country",
},
}
result := engine.Evaluate(intake)
controlIDs := make(map[string]bool)
for _, ctrl := range result.RequiredControls {
controlIDs[ctrl.ID] = true
}
if !controlIDs["C_SCC"] {
t.Error("Expected C_SCC control for third country transfer")
}
}
// ============================================================================
// Policy Version Tests
// ============================================================================
func TestPolicyEngine_PolicyVersion(t *testing.T) {
root := getProjectRoot(t)
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
engine, err := NewPolicyEngineFromPath(policyPath)
if err != nil {
t.Fatalf("Failed to create policy engine: %v", err)
}
version := engine.GetPolicyVersion()
// Version should be non-empty and follow semver pattern
if version == "" {
t.Error("Policy version should not be empty")
}
// Check for basic semver pattern (x.y.z)
parts := 0
for _, c := range version {
if c == '.' {
parts++
}
}
if parts < 2 {
t.Errorf("Policy version should follow semver (x.y.z), got %s", version)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
package ucca
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles UCCA data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new UCCA store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Assessment CRUD Operations
// ============================================================================
// CreateAssessment creates a new assessment
func (s *Store) CreateAssessment(ctx context.Context, a *Assessment) error {
a.ID = uuid.New()
a.CreatedAt = time.Now().UTC()
a.UpdatedAt = a.CreatedAt
if a.PolicyVersion == "" {
a.PolicyVersion = "1.0.0"
}
if a.Status == "" {
a.Status = "completed"
}
// Marshal JSONB fields
intake, _ := json.Marshal(a.Intake)
triggeredRules, _ := json.Marshal(a.TriggeredRules)
requiredControls, _ := json.Marshal(a.RequiredControls)
recommendedArchitecture, _ := json.Marshal(a.RecommendedArchitecture)
forbiddenPatterns, _ := json.Marshal(a.ForbiddenPatterns)
exampleMatches, _ := json.Marshal(a.ExampleMatches)
_, err := s.pool.Exec(ctx, `
INSERT INTO ucca_assessments (
id, tenant_id, namespace_id, title, policy_version, status,
intake, use_case_text_stored, use_case_text_hash,
feasibility, risk_level, complexity, risk_score,
triggered_rules, required_controls, recommended_architecture,
forbidden_patterns, example_matches,
dsfa_recommended, art22_risk, training_allowed,
explanation_text, explanation_generated_at, explanation_model,
domain, created_at, updated_at, created_by
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
$10, $11, $12, $13,
$14, $15, $16,
$17, $18,
$19, $20, $21,
$22, $23, $24,
$25, $26, $27, $28
)
`,
a.ID, a.TenantID, a.NamespaceID, a.Title, a.PolicyVersion, a.Status,
intake, a.UseCaseTextStored, a.UseCaseTextHash,
string(a.Feasibility), string(a.RiskLevel), string(a.Complexity), a.RiskScore,
triggeredRules, requiredControls, recommendedArchitecture,
forbiddenPatterns, exampleMatches,
a.DSFARecommended, a.Art22Risk, string(a.TrainingAllowed),
a.ExplanationText, a.ExplanationGeneratedAt, a.ExplanationModel,
string(a.Domain), a.CreatedAt, a.UpdatedAt, a.CreatedBy,
)
return err
}
// GetAssessment retrieves an assessment by ID
func (s *Store) GetAssessment(ctx context.Context, id uuid.UUID) (*Assessment, error) {
var a Assessment
var intake, triggeredRules, requiredControls, recommendedArchitecture, forbiddenPatterns, exampleMatches []byte
var feasibility, riskLevel, complexity, trainingAllowed, domain string
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, namespace_id, title, policy_version, status,
intake, use_case_text_stored, use_case_text_hash,
feasibility, risk_level, complexity, risk_score,
triggered_rules, required_controls, recommended_architecture,
forbidden_patterns, example_matches,
dsfa_recommended, art22_risk, training_allowed,
explanation_text, explanation_generated_at, explanation_model,
domain, created_at, updated_at, created_by
FROM ucca_assessments WHERE id = $1
`, id).Scan(
&a.ID, &a.TenantID, &a.NamespaceID, &a.Title, &a.PolicyVersion, &a.Status,
&intake, &a.UseCaseTextStored, &a.UseCaseTextHash,
&feasibility, &riskLevel, &complexity, &a.RiskScore,
&triggeredRules, &requiredControls, &recommendedArchitecture,
&forbiddenPatterns, &exampleMatches,
&a.DSFARecommended, &a.Art22Risk, &trainingAllowed,
&a.ExplanationText, &a.ExplanationGeneratedAt, &a.ExplanationModel,
&domain, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
// Unmarshal JSONB fields
json.Unmarshal(intake, &a.Intake)
json.Unmarshal(triggeredRules, &a.TriggeredRules)
json.Unmarshal(requiredControls, &a.RequiredControls)
json.Unmarshal(recommendedArchitecture, &a.RecommendedArchitecture)
json.Unmarshal(forbiddenPatterns, &a.ForbiddenPatterns)
json.Unmarshal(exampleMatches, &a.ExampleMatches)
// Convert string fields to typed constants
a.Feasibility = Feasibility(feasibility)
a.RiskLevel = RiskLevel(riskLevel)
a.Complexity = Complexity(complexity)
a.TrainingAllowed = TrainingAllowed(trainingAllowed)
a.Domain = Domain(domain)
return &a, nil
}
// ListAssessments lists assessments for a tenant with optional filters
func (s *Store) ListAssessments(ctx context.Context, tenantID uuid.UUID, filters *AssessmentFilters) ([]Assessment, error) {
query := `
SELECT
id, tenant_id, namespace_id, title, policy_version, status,
intake, use_case_text_stored, use_case_text_hash,
feasibility, risk_level, complexity, risk_score,
triggered_rules, required_controls, recommended_architecture,
forbidden_patterns, example_matches,
dsfa_recommended, art22_risk, training_allowed,
explanation_text, explanation_generated_at, explanation_model,
domain, created_at, updated_at, created_by
FROM ucca_assessments WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
// Apply filters
if filters != nil {
if filters.Feasibility != "" {
query += " AND feasibility = $" + itoa(argIdx)
args = append(args, filters.Feasibility)
argIdx++
}
if filters.Domain != "" {
query += " AND domain = $" + itoa(argIdx)
args = append(args, filters.Domain)
argIdx++
}
if filters.RiskLevel != "" {
query += " AND risk_level = $" + itoa(argIdx)
args = append(args, filters.RiskLevel)
argIdx++
}
}
query += " ORDER BY created_at DESC"
// Apply limit
if filters != nil && filters.Limit > 0 {
query += " LIMIT $" + itoa(argIdx)
args = append(args, filters.Limit)
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var assessments []Assessment
for rows.Next() {
var a Assessment
var intake, triggeredRules, requiredControls, recommendedArchitecture, forbiddenPatterns, exampleMatches []byte
var feasibility, riskLevel, complexity, trainingAllowed, domain string
err := rows.Scan(
&a.ID, &a.TenantID, &a.NamespaceID, &a.Title, &a.PolicyVersion, &a.Status,
&intake, &a.UseCaseTextStored, &a.UseCaseTextHash,
&feasibility, &riskLevel, &complexity, &a.RiskScore,
&triggeredRules, &requiredControls, &recommendedArchitecture,
&forbiddenPatterns, &exampleMatches,
&a.DSFARecommended, &a.Art22Risk, &trainingAllowed,
&a.ExplanationText, &a.ExplanationGeneratedAt, &a.ExplanationModel,
&domain, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy,
)
if err != nil {
return nil, err
}
// Unmarshal JSONB fields
json.Unmarshal(intake, &a.Intake)
json.Unmarshal(triggeredRules, &a.TriggeredRules)
json.Unmarshal(requiredControls, &a.RequiredControls)
json.Unmarshal(recommendedArchitecture, &a.RecommendedArchitecture)
json.Unmarshal(forbiddenPatterns, &a.ForbiddenPatterns)
json.Unmarshal(exampleMatches, &a.ExampleMatches)
// Convert string fields
a.Feasibility = Feasibility(feasibility)
a.RiskLevel = RiskLevel(riskLevel)
a.Complexity = Complexity(complexity)
a.TrainingAllowed = TrainingAllowed(trainingAllowed)
a.Domain = Domain(domain)
assessments = append(assessments, a)
}
return assessments, nil
}
// DeleteAssessment deletes an assessment by ID
func (s *Store) DeleteAssessment(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM ucca_assessments WHERE id = $1", id)
return err
}
// UpdateExplanation updates the LLM explanation for an assessment
func (s *Store) UpdateExplanation(ctx context.Context, id uuid.UUID, explanation string, model string) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE ucca_assessments SET
explanation_text = $2,
explanation_generated_at = $3,
explanation_model = $4,
updated_at = $5
WHERE id = $1
`, id, explanation, now, model, now)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// UCCAStats contains UCCA module statistics
type UCCAStats struct {
TotalAssessments int `json:"total_assessments"`
AssessmentsYES int `json:"assessments_yes"`
AssessmentsCONDITIONAL int `json:"assessments_conditional"`
AssessmentsNO int `json:"assessments_no"`
AverageRiskScore int `json:"average_risk_score"`
DSFARecommendedCount int `json:"dsfa_recommended_count"`
}
// GetStats returns UCCA statistics for a tenant
func (s *Store) GetStats(ctx context.Context, tenantID uuid.UUID) (*UCCAStats, error) {
stats := &UCCAStats{}
// Total count
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalAssessments)
// Count by feasibility
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND feasibility = 'YES'",
tenantID).Scan(&stats.AssessmentsYES)
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND feasibility = 'CONDITIONAL'",
tenantID).Scan(&stats.AssessmentsCONDITIONAL)
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND feasibility = 'NO'",
tenantID).Scan(&stats.AssessmentsNO)
// Average risk score
s.pool.QueryRow(ctx,
"SELECT COALESCE(AVG(risk_score)::int, 0) FROM ucca_assessments WHERE tenant_id = $1",
tenantID).Scan(&stats.AverageRiskScore)
// DSFA recommended count
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND dsfa_recommended = true",
tenantID).Scan(&stats.DSFARecommendedCount)
return stats, nil
}
// ============================================================================
// Filter Types
// ============================================================================
// AssessmentFilters defines filters for listing assessments
type AssessmentFilters struct {
Feasibility string
Domain string
RiskLevel string
Limit int
}
// ============================================================================
// Helpers
// ============================================================================
// itoa converts int to string for query building
func itoa(i int) string {
return string(rune('0' + i))
}

View File

@@ -0,0 +1,439 @@
package ucca
// ============================================================================
// Unified Facts Model
// ============================================================================
//
// UnifiedFacts aggregates all facts about an organization that are needed
// to determine which regulations apply and what obligations arise.
// This model is regulation-agnostic and serves as input for all modules.
//
// ============================================================================
// UnifiedFacts is the comprehensive facts model for all regulatory assessments
type UnifiedFacts struct {
// Existing UCCA Facts (for backwards compatibility)
UCCAFacts *UseCaseIntake `json:"ucca_facts,omitempty"`
// Organization facts
Organization OrganizationFacts `json:"organization"`
// Sector/Industry facts (for NIS2, sector-specific regulations)
Sector SectorFacts `json:"sector"`
// Data protection facts (for DSGVO)
DataProtection DataProtectionFacts `json:"data_protection"`
// AI usage facts (for AI Act)
AIUsage AIUsageFacts `json:"ai_usage"`
// Financial sector facts (for MaRisk, DORA)
Financial FinancialFacts `json:"financial"`
// IT Security facts (for NIS2)
ITSecurity ITSecurityFacts `json:"it_security"`
// Supply chain facts
SupplyChain SupplyChainFacts `json:"supply_chain"`
// Personnel facts
Personnel PersonnelFacts `json:"personnel"`
}
// OrganizationFacts contains basic organizational information
type OrganizationFacts struct {
// Basic info
Name string `json:"name,omitempty"`
LegalForm string `json:"legal_form,omitempty"` // e.g., "GmbH", "AG", "e.V."
Country string `json:"country"` // e.g., "DE", "AT", "CH"
EUMember bool `json:"eu_member"`
// Organization type
IsPublicAuthority bool `json:"is_public_authority"` // Public authority or body
// Size metrics (KMU criteria)
EmployeeCount int `json:"employee_count"`
AnnualRevenue float64 `json:"annual_revenue"` // in EUR
BalanceSheetTotal float64 `json:"balance_sheet_total"` // in EUR
// Calculated size category
SizeCategory string `json:"size_category,omitempty"` // "micro", "small", "medium", "large"
// Group structure
IsPartOfGroup bool `json:"is_part_of_group"`
ParentCompany string `json:"parent_company,omitempty"`
GroupEmployees int `json:"group_employees,omitempty"`
GroupRevenue float64 `json:"group_revenue,omitempty"`
}
// SectorFacts contains industry/sector information for regulatory classification
type SectorFacts struct {
// Primary sector classification
PrimarySector string `json:"primary_sector"` // NIS2 Annex I/II sector codes
SubSector string `json:"sub_sector,omitempty"`
NACECode string `json:"nace_code,omitempty"`
// KRITIS classification (German critical infrastructure)
IsKRITIS bool `json:"is_kritis"`
KRITISThresholdMet bool `json:"kritis_threshold_met"`
KRITISSector string `json:"kritis_sector,omitempty"`
// Special services (NIS2-specific)
SpecialServices []string `json:"special_services,omitempty"` // "dns", "tld", "cloud", "datacenter", "cdn", "msp", "mssp", "trust_service", "public_network", "electronic_comms"
// Public administration
IsPublicAdministration bool `json:"is_public_administration"`
PublicAdminLevel string `json:"public_admin_level,omitempty"` // "federal", "state", "municipal"
// Healthcare specific
IsHealthcareProvider bool `json:"is_healthcare_provider"`
HasPatientData bool `json:"has_patient_data"`
// Financial specific
IsFinancialInstitution bool `json:"is_financial_institution"`
FinancialEntityType string `json:"financial_entity_type,omitempty"` // DORA entity types
}
// DataProtectionFacts contains GDPR-relevant information
type DataProtectionFacts struct {
// Data processing basics
ProcessesPersonalData bool `json:"processes_personal_data"`
ProcessesSpecialCategories bool `json:"processes_special_categories"` // Art. 9 DSGVO
ProcessesMinorData bool `json:"processes_minor_data"`
ProcessesCriminalData bool `json:"processes_criminal_data"` // Art. 10 DSGVO
// Controller/Processor role
IsController bool `json:"is_controller"` // Acts as controller
IsProcessor bool `json:"is_processor"` // Acts as processor for others
// Territorial scope (Art. 3)
OffersToEU bool `json:"offers_to_eu"` // Offers goods/services to EU
MonitorsEUIndividuals bool `json:"monitors_eu_individuals"` // Monitors behavior of EU individuals
// Scale of processing
LargeScaleProcessing bool `json:"large_scale_processing"`
SystematicMonitoring bool `json:"systematic_monitoring"`
Profiling bool `json:"profiling"`
AutomatedDecisionMaking bool `json:"automated_decision_making"` // Art. 22 DSGVO
AutomatedDecisions bool `json:"automated_decisions"` // Alias for automated_decision_making
LegalEffects bool `json:"legal_effects"` // Automated decisions with legal effects
// Special categories (Art. 9) - detailed
SpecialCategories []string `json:"special_categories,omitempty"` // racial_ethnic_origin, political_opinions, etc.
// High-risk activities (Art. 35)
HighRiskActivities []string `json:"high_risk_activities,omitempty"` // systematic_monitoring, automated_decisions, etc.
// Data subjects
DataSubjectCount int `json:"data_subject_count"` // Approximate number
DataSubjectCountRange string `json:"data_subject_count_range,omitempty"` // "< 1000", "1000-10000", "> 10000", "> 100000"
// Data transfers
TransfersToThirdCountries bool `json:"transfers_to_third_countries"`
CrossBorderProcessing bool `json:"cross_border_processing"` // Processing across EU borders
SCCsInPlace bool `json:"sccs_in_place"`
BindingCorporateRules bool `json:"binding_corporate_rules"`
// External processing
UsesExternalProcessor bool `json:"uses_external_processor"`
// DSB requirement triggers
RequiresDSBByLaw bool `json:"requires_dsb_by_law"`
HasAppointedDSB bool `json:"has_appointed_dsb"`
DSBIsInternal bool `json:"dsb_is_internal"`
}
// AIUsageFacts contains AI Act relevant information
type AIUsageFacts struct {
// Basic AI usage
UsesAI bool `json:"uses_ai"`
AIApplications []string `json:"ai_applications,omitempty"`
// AI Act role
IsAIProvider bool `json:"is_ai_provider"` // Develops/provides AI systems
IsAIDeployer bool `json:"is_ai_deployer"` // Uses AI systems
IsAIDistributor bool `json:"is_ai_distributor"`
IsAIImporter bool `json:"is_ai_importer"`
// Risk categories (pre-classification)
HasHighRiskAI bool `json:"has_high_risk_ai"`
HasLimitedRiskAI bool `json:"has_limited_risk_ai"`
HasMinimalRiskAI bool `json:"has_minimal_risk_ai"`
// Specific high-risk categories (Annex III)
BiometricIdentification bool `json:"biometric_identification"` // Real-time, remote
CriticalInfrastructure bool `json:"critical_infrastructure"` // AI in CI
EducationAccess bool `json:"education_access"` // Admission, assessment
EmploymentDecisions bool `json:"employment_decisions"` // Recruitment, evaluation
EssentialServices bool `json:"essential_services"` // Credit, benefits
LawEnforcement bool `json:"law_enforcement"`
MigrationAsylum bool `json:"migration_asylum"`
JusticeAdministration bool `json:"justice_administration"`
// Prohibited practices (Art. 5)
SocialScoring bool `json:"social_scoring"`
EmotionRecognition bool `json:"emotion_recognition"` // Workplace/education
PredictivePolicingIndividual bool `json:"predictive_policing_individual"`
// GPAI (General Purpose AI)
UsesGPAI bool `json:"uses_gpai"`
GPAIWithSystemicRisk bool `json:"gpai_with_systemic_risk"`
// Transparency obligations
AIInteractsWithNaturalPersons bool `json:"ai_interacts_with_natural_persons"`
GeneratesDeepfakes bool `json:"generates_deepfakes"`
}
// FinancialFacts contains financial regulation specific information
type FinancialFacts struct {
// Entity type (DORA scope)
EntityType string `json:"entity_type,omitempty"` // Credit institution, payment service provider, etc.
IsRegulated bool `json:"is_regulated"`
// DORA specific
DORAApplies bool `json:"dora_applies"`
HasCriticalICT bool `json:"has_critical_ict"`
ICTOutsourced bool `json:"ict_outsourced"`
ICTProviderLocation string `json:"ict_provider_location,omitempty"` // "EU", "EEA", "third_country"
ConcentrationRisk bool `json:"concentration_risk"`
// MaRisk/BAIT specific (Germany)
MaRiskApplies bool `json:"marisk_applies"`
BAITApplies bool `json:"bait_applies"`
// AI in financial services
AIAffectsCustomers bool `json:"ai_affects_customers"`
AlgorithmicTrading bool `json:"algorithmic_trading"`
AIRiskAssessment bool `json:"ai_risk_assessment"`
AIAMLKYC bool `json:"ai_aml_kyc"`
}
// ITSecurityFacts contains IT security posture information
type ITSecurityFacts struct {
// ISMS status
HasISMS bool `json:"has_isms"`
ISO27001Certified bool `json:"iso27001_certified"`
ISO27001CertExpiry string `json:"iso27001_cert_expiry,omitempty"`
// Security processes
HasIncidentProcess bool `json:"has_incident_process"`
HasVulnerabilityMgmt bool `json:"has_vulnerability_mgmt"`
HasPatchMgmt bool `json:"has_patch_mgmt"`
HasAccessControl bool `json:"has_access_control"`
HasMFA bool `json:"has_mfa"`
HasEncryption bool `json:"has_encryption"`
HasBackup bool `json:"has_backup"`
HasDisasterRecovery bool `json:"has_disaster_recovery"`
// BCM
HasBCM bool `json:"has_bcm"`
BCMTested bool `json:"bcm_tested"`
// Network security
HasNetworkSegmentation bool `json:"has_network_segmentation"`
HasFirewall bool `json:"has_firewall"`
HasIDS bool `json:"has_ids"`
// Monitoring
HasSecurityMonitoring bool `json:"has_security_monitoring"`
Has24x7SOC bool `json:"has_24x7_soc"`
// Awareness
SecurityAwarenessTraining bool `json:"security_awareness_training"`
RegularSecurityTraining bool `json:"regular_security_training"`
// Certifications
OtherCertifications []string `json:"other_certifications,omitempty"` // SOC2, BSI C5, etc.
}
// SupplyChainFacts contains supply chain security information
type SupplyChainFacts struct {
// Supply chain basics
HasSupplyChainRiskMgmt bool `json:"has_supply_chain_risk_mgmt"`
SupplierCount int `json:"supplier_count,omitempty"`
CriticalSupplierCount int `json:"critical_supplier_count,omitempty"`
// Third party risk
ThirdPartyAudits bool `json:"third_party_audits"`
SupplierSecurityReqs bool `json:"supplier_security_reqs"`
// ICT supply chain (NIS2)
HasICTSupplyChainPolicy bool `json:"has_ict_supply_chain_policy"`
ICTSupplierDiversity bool `json:"ict_supplier_diversity"`
}
// PersonnelFacts contains workforce-related information
type PersonnelFacts struct {
// Security personnel
HasCISO bool `json:"has_ciso"`
HasDedicatedSecurityTeam bool `json:"has_dedicated_security_team"`
SecurityTeamSize int `json:"security_team_size,omitempty"`
// Compliance personnel
HasComplianceOfficer bool `json:"has_compliance_officer"`
HasDPO bool `json:"has_dpo"`
DPOIsExternal bool `json:"dpo_is_external"`
// AI personnel (AI Act)
HasAICompetence bool `json:"has_ai_competence"`
HasAIGovernance bool `json:"has_ai_governance"`
}
// ============================================================================
// Helper Methods
// ============================================================================
// CalculateSizeCategory determines the organization size based on EU SME criteria
func (o *OrganizationFacts) CalculateSizeCategory() string {
// Use group figures if part of a group
employees := o.EmployeeCount
revenue := o.AnnualRevenue
balance := o.BalanceSheetTotal
if o.IsPartOfGroup && o.GroupEmployees > 0 {
employees = o.GroupEmployees
}
if o.IsPartOfGroup && o.GroupRevenue > 0 {
revenue = o.GroupRevenue
}
// EU SME Criteria (Recommendation 2003/361/EC)
// Micro: <10 employees AND (revenue OR balance) <= €2m
// Small: <50 employees AND (revenue OR balance) <= €10m
// Medium: <250 employees AND revenue <= €50m OR balance <= €43m
// Large: everything else
if employees < 10 && (revenue <= 2_000_000 || balance <= 2_000_000) {
return "micro"
}
if employees < 50 && (revenue <= 10_000_000 || balance <= 10_000_000) {
return "small"
}
if employees < 250 && (revenue <= 50_000_000 || balance <= 43_000_000) {
return "medium"
}
return "large"
}
// IsSME returns true if the organization qualifies as an SME
func (o *OrganizationFacts) IsSME() bool {
category := o.CalculateSizeCategory()
return category == "micro" || category == "small" || category == "medium"
}
// MeetsNIS2SizeThreshold checks if organization meets NIS2 size threshold
// (>= 50 employees OR >= €10m revenue AND >= €10m balance)
func (o *OrganizationFacts) MeetsNIS2SizeThreshold() bool {
employees := o.EmployeeCount
revenue := o.AnnualRevenue
balance := o.BalanceSheetTotal
if o.IsPartOfGroup && o.GroupEmployees > 0 {
employees = o.GroupEmployees
}
if o.IsPartOfGroup && o.GroupRevenue > 0 {
revenue = o.GroupRevenue
}
// NIS2 size threshold: medium+ enterprises
// >= 50 employees OR (>= €10m revenue AND >= €10m balance)
if employees >= 50 {
return true
}
if revenue >= 10_000_000 && balance >= 10_000_000 {
return true
}
return false
}
// MeetsNIS2LargeThreshold checks if organization meets NIS2 "large" threshold
// (>= 250 employees OR >= €50m revenue)
func (o *OrganizationFacts) MeetsNIS2LargeThreshold() bool {
employees := o.EmployeeCount
revenue := o.AnnualRevenue
if o.IsPartOfGroup && o.GroupEmployees > 0 {
employees = o.GroupEmployees
}
if o.IsPartOfGroup && o.GroupRevenue > 0 {
revenue = o.GroupRevenue
}
return employees >= 250 || revenue >= 50_000_000
}
// NewUnifiedFacts creates a new UnifiedFacts with default values
func NewUnifiedFacts() *UnifiedFacts {
return &UnifiedFacts{
Organization: OrganizationFacts{Country: "DE", EUMember: true},
Sector: SectorFacts{},
DataProtection: DataProtectionFacts{},
AIUsage: AIUsageFacts{},
Financial: FinancialFacts{},
ITSecurity: ITSecurityFacts{},
SupplyChain: SupplyChainFacts{},
Personnel: PersonnelFacts{},
}
}
// FromUseCaseIntake creates UnifiedFacts from an existing UseCaseIntake
func (f *UnifiedFacts) FromUseCaseIntake(intake *UseCaseIntake) {
f.UCCAFacts = intake
// Map data types
f.DataProtection.ProcessesPersonalData = intake.DataTypes.PersonalData
f.DataProtection.ProcessesSpecialCategories = intake.DataTypes.Article9Data
f.DataProtection.ProcessesMinorData = intake.DataTypes.MinorData
f.DataProtection.Profiling = intake.Purpose.Profiling
f.DataProtection.AutomatedDecisionMaking = intake.Purpose.DecisionMaking
// Map AI usage
if intake.ModelUsage.RAG || intake.ModelUsage.Training || intake.ModelUsage.Finetune || intake.ModelUsage.Inference {
f.AIUsage.UsesAI = true
f.AIUsage.IsAIDeployer = true
}
// Map sector from domain
f.MapDomainToSector(string(intake.Domain))
}
// MapDomainToSector maps a UCCA domain to sector facts
func (f *UnifiedFacts) MapDomainToSector(domain string) {
// Map common domains to NIS2 sectors
sectorMap := map[string]string{
"energy": "energy",
"utilities": "energy",
"oil_gas": "energy",
"banking": "banking_financial",
"finance": "banking_financial",
"insurance": "banking_financial",
"investment": "banking_financial",
"healthcare": "health",
"pharma": "health",
"medical_devices": "health",
"logistics": "transport",
"telecom": "digital_infrastructure",
"it_services": "ict_service_mgmt",
"cybersecurity": "ict_service_mgmt",
"public_sector": "public_administration",
"defense": "public_administration",
"food_beverage": "food",
"chemicals": "chemicals",
"research": "research",
"education": "education",
"higher_education": "education",
}
if sector, ok := sectorMap[domain]; ok {
f.Sector.PrimarySector = sector
} else {
f.Sector.PrimarySector = "other"
}
// Set financial flags
if domain == "banking" || domain == "finance" || domain == "insurance" || domain == "investment" {
f.Sector.IsFinancialInstitution = true
f.Financial.IsRegulated = true
f.Financial.DORAApplies = true
}
}