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:
769
ai-compliance-sdk/internal/ucca/ai_act_module.go
Normal file
769
ai-compliance-sdk/internal/ucca/ai_act_module.go
Normal 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.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
343
ai-compliance-sdk/internal/ucca/ai_act_module_test.go
Normal file
343
ai-compliance-sdk/internal/ucca/ai_act_module_test.go
Normal 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
|
||||
}
|
||||
719
ai-compliance-sdk/internal/ucca/dsgvo_module.go
Normal file
719
ai-compliance-sdk/internal/ucca/dsgvo_module.go
Normal 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.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
286
ai-compliance-sdk/internal/ucca/escalation_models.go
Normal file
286
ai-compliance-sdk/internal/ucca/escalation_models.go
Normal 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"`
|
||||
}
|
||||
502
ai-compliance-sdk/internal/ucca/escalation_store.go
Normal file
502
ai-compliance-sdk/internal/ucca/escalation_store.go
Normal 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
|
||||
}
|
||||
446
ai-compliance-sdk/internal/ucca/escalation_test.go
Normal file
446
ai-compliance-sdk/internal/ucca/escalation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
286
ai-compliance-sdk/internal/ucca/examples.go
Normal file
286
ai-compliance-sdk/internal/ucca/examples.go
Normal 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
|
||||
}
|
||||
734
ai-compliance-sdk/internal/ucca/financial_policy.go
Normal file
734
ai-compliance-sdk/internal/ucca/financial_policy.go
Normal 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"`
|
||||
}
|
||||
618
ai-compliance-sdk/internal/ucca/financial_policy_test.go
Normal file
618
ai-compliance-sdk/internal/ucca/financial_policy_test.go
Normal 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
|
||||
}
|
||||
394
ai-compliance-sdk/internal/ucca/legal_rag.go
Normal file
394
ai-compliance-sdk/internal/ucca/legal_rag.go
Normal 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] + "..."
|
||||
}
|
||||
583
ai-compliance-sdk/internal/ucca/license_policy.go
Normal file
583
ai-compliance-sdk/internal/ucca/license_policy.go
Normal 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()
|
||||
}
|
||||
940
ai-compliance-sdk/internal/ucca/license_policy_test.go
Normal file
940
ai-compliance-sdk/internal/ucca/license_policy_test.go
Normal 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
|
||||
}
|
||||
523
ai-compliance-sdk/internal/ucca/models.go
Normal file
523
ai-compliance-sdk/internal/ucca/models.go
Normal 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"
|
||||
)
|
||||
762
ai-compliance-sdk/internal/ucca/nis2_module.go
Normal file
762
ai-compliance-sdk/internal/ucca/nis2_module.go
Normal 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
556
ai-compliance-sdk/internal/ucca/nis2_module_test.go
Normal file
556
ai-compliance-sdk/internal/ucca/nis2_module_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
381
ai-compliance-sdk/internal/ucca/obligations_framework.go
Normal file
381
ai-compliance-sdk/internal/ucca/obligations_framework.go
Normal 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"`
|
||||
}
|
||||
480
ai-compliance-sdk/internal/ucca/obligations_registry.go
Normal file
480
ai-compliance-sdk/internal/ucca/obligations_registry.go
Normal 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
|
||||
}
|
||||
216
ai-compliance-sdk/internal/ucca/obligations_store.go
Normal file
216
ai-compliance-sdk/internal/ucca/obligations_store.go
Normal 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);
|
||||
`
|
||||
}
|
||||
279
ai-compliance-sdk/internal/ucca/patterns.go
Normal file
279
ai-compliance-sdk/internal/ucca/patterns.go
Normal 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
|
||||
}
|
||||
510
ai-compliance-sdk/internal/ucca/pdf_export.go
Normal file
510
ai-compliance-sdk/internal/ucca/pdf_export.go
Normal 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
|
||||
}
|
||||
238
ai-compliance-sdk/internal/ucca/pdf_export_test.go
Normal file
238
ai-compliance-sdk/internal/ucca/pdf_export_test.go
Normal 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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
882
ai-compliance-sdk/internal/ucca/policy_engine.go
Normal file
882
ai-compliance-sdk/internal/ucca/policy_engine.go
Normal 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"
|
||||
}
|
||||
940
ai-compliance-sdk/internal/ucca/policy_engine_test.go
Normal file
940
ai-compliance-sdk/internal/ucca/policy_engine_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1231
ai-compliance-sdk/internal/ucca/rules.go
Normal file
1231
ai-compliance-sdk/internal/ucca/rules.go
Normal file
File diff suppressed because it is too large
Load Diff
313
ai-compliance-sdk/internal/ucca/store.go
Normal file
313
ai-compliance-sdk/internal/ucca/store.go
Normal 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))
|
||||
}
|
||||
439
ai-compliance-sdk/internal/ucca/unified_facts.go
Normal file
439
ai-compliance-sdk/internal/ucca/unified_facts.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user