Compare commits
75 Commits
148c7ba3af
...
feature/be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd64e33e88 | ||
|
|
2f8269d115 | ||
|
|
532febe35c | ||
|
|
0a0863f31c | ||
|
|
d892ad161f | ||
|
|
17153ccbe8 | ||
|
|
352d7112c9 | ||
|
|
0957254547 | ||
|
|
f17608a956 | ||
|
|
ce3df9f080 | ||
|
|
2da39e035d | ||
|
|
1989c410a9 | ||
|
|
c55a6ab995 | ||
|
|
bc75b4455d | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 | ||
|
|
6d3bdf8e74 | ||
|
|
200facda6a | ||
|
|
9282850138 | ||
|
|
770f0b5ab0 | ||
|
|
35784c35eb | ||
|
|
cce2707c03 | ||
|
|
2efc738803 | ||
|
|
e6201d5239 | ||
|
|
48ca0a6bef | ||
|
|
1a63f5857b | ||
|
|
295c18c6f7 | ||
|
|
649a3c5e4e | ||
|
|
bdd2f6fa0f | ||
|
|
ac6134ce6d | ||
|
|
0027f78fc5 | ||
|
|
b29a7caee7 | ||
|
|
a14e2f3a00 | ||
|
|
71b8c33270 | ||
|
|
f2924a58ed | ||
|
|
643b26618f | ||
|
|
c52dbdb8f1 | ||
|
|
c3a53fe5d2 | ||
|
|
df5b6d69ef | ||
|
|
4f6ac9b23a | ||
|
|
5ea31a3236 | ||
|
|
95c371e9a5 | ||
|
|
b1627252ee | ||
|
|
2a0449c9b7 | ||
|
|
92d37a1660 | ||
|
|
0e16640c28 | ||
|
|
24f02b52ed | ||
|
|
9b0f25c105 | ||
|
|
1cc34c23d9 | ||
|
|
5dd7a27336 | ||
|
|
c3afa628ed | ||
|
|
4b1eede45b | ||
|
|
2a70441eaa | ||
|
|
f2819b99af | ||
|
|
3bb9fffab6 |
@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
|
||||
expect(scriptContent).toContain('chunk_strategy=recursive')
|
||||
})
|
||||
|
||||
it('should use chunk_size=512', () => {
|
||||
expect(scriptContent).toContain('chunk_size=512')
|
||||
it('should use chunk_size=1024', () => {
|
||||
expect(scriptContent).toContain('chunk_size=1024')
|
||||
})
|
||||
|
||||
it('should use chunk_overlap=50', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=50')
|
||||
it('should use chunk_overlap=128', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=128')
|
||||
})
|
||||
|
||||
it('should validate minimum file size', () => {
|
||||
|
||||
@@ -591,12 +591,43 @@ async function handleV2Draft(body: Record<string, unknown>): Promise<NextRespons
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
// Anti-Fake-Evidence: Truth label for all LLM-generated content
|
||||
const truthLabel = {
|
||||
generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated',
|
||||
may_be_used_as_evidence: false,
|
||||
generated_by: 'system',
|
||||
}
|
||||
|
||||
// Fire-and-forget: persist LLM audit trail to backend
|
||||
try {
|
||||
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
|
||||
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entity_type: 'document',
|
||||
entity_id: null,
|
||||
generation_mode: 'draft_assistance',
|
||||
truth_status: 'generated',
|
||||
may_be_used_as_evidence: false,
|
||||
llm_model: LLM_MODEL,
|
||||
llm_provider: 'ollama',
|
||||
input_summary: `${documentType} draft generation`,
|
||||
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
|
||||
}),
|
||||
}).catch(() => {/* fire-and-forget */})
|
||||
} catch {
|
||||
// LLM audit persistence failure should not block the response
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: Math.round(totalTokens),
|
||||
pipelineVersion: 'v2',
|
||||
auditTrail,
|
||||
truthLabel,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
/**
|
||||
* Anti-Fake-Evidence: Verbotene Formulierungen
|
||||
*
|
||||
* Flags formulations that falsely claim compliance without evidence.
|
||||
* Only allowed when: control_status=pass AND confidence >= E2 AND
|
||||
* truth_status in (validated_internal, accepted_by_auditor).
|
||||
*/
|
||||
interface EvidenceContext {
|
||||
controlStatus?: string
|
||||
confidenceLevel?: string
|
||||
truthStatus?: string
|
||||
}
|
||||
|
||||
const FORBIDDEN_PATTERNS: Array<{
|
||||
pattern: RegExp
|
||||
label: string
|
||||
safeAlternative: string
|
||||
}> = [
|
||||
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
|
||||
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
|
||||
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
|
||||
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
|
||||
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
|
||||
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
|
||||
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
|
||||
]
|
||||
|
||||
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
|
||||
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
|
||||
|
||||
function checkForbiddenFormulations(
|
||||
content: string,
|
||||
evidenceContext?: EvidenceContext,
|
||||
): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = []
|
||||
|
||||
if (!content) return findings
|
||||
|
||||
// If evidence context shows sufficient proof, allow the formulations
|
||||
if (evidenceContext) {
|
||||
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
|
||||
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
|
||||
if (
|
||||
controlStatus === 'pass' &&
|
||||
confLevel >= CONFIDENCE_ORDER.E2 &&
|
||||
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
|
||||
) {
|
||||
return findings // Formulations are backed by real evidence
|
||||
}
|
||||
}
|
||||
|
||||
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
|
||||
// Reset regex state for global patterns
|
||||
pattern.lastIndex = 0
|
||||
if (pattern.test(content)) {
|
||||
findings.push({
|
||||
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
|
||||
severity: 'error',
|
||||
category: 'forbidden_formulation' as ValidationFinding['category'],
|
||||
title: `Verbotene Formulierung: "${label}"`,
|
||||
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
|
||||
documentType: 'vvt' as ScopeDocumentType,
|
||||
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
/**
|
||||
* Stufe 1: Deterministische Pruefung
|
||||
*/
|
||||
@@ -221,10 +291,18 @@ export async function POST(request: NextRequest) {
|
||||
// LLM unavailable, continue with deterministic results only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
|
||||
// ---------------------------------------------------------------
|
||||
const forbiddenFindings = checkForbiddenFormulations(
|
||||
draftContent || '',
|
||||
validationContext.evidenceContext,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Combine results
|
||||
// ---------------------------------------------------------------
|
||||
const allFindings = [...deterministicFindings, ...llmFindings]
|
||||
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
|
||||
const errors = allFindings.filter(f => f.severity === 'error')
|
||||
const warnings = allFindings.filter(f => f.severity === 'warning')
|
||||
const suggestions = allFindings.filter(f => f.severity === 'suggestion')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
case 'controls': {
|
||||
const controlParams = new URLSearchParams()
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
||||
'target_audience', 'source', 'search', 'sort', 'order', 'limit', 'offset']
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
|
||||
for (const key of passthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) controlParams.set(key, val)
|
||||
@@ -39,8 +39,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
case 'controls-count': {
|
||||
const countParams = new URLSearchParams()
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
||||
'target_audience', 'source', 'search']
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of countPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) countParams.set(key, val)
|
||||
@@ -50,9 +50,18 @@ export async function GET(request: NextRequest) {
|
||||
break
|
||||
}
|
||||
|
||||
case 'controls-meta':
|
||||
backendPath = '/api/compliance/v1/canonical/controls-meta'
|
||||
case 'controls-meta': {
|
||||
const metaParams = new URLSearchParams()
|
||||
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||
for (const key of metaPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
if (val) metaParams.set(key, val)
|
||||
}
|
||||
const metaQs = metaParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'control': {
|
||||
const controlId = searchParams.get('id')
|
||||
@@ -99,6 +108,28 @@ export async function GET(request: NextRequest) {
|
||||
backendPath = '/api/compliance/v1/canonical/categories'
|
||||
break
|
||||
|
||||
case 'traceability': {
|
||||
const traceId = searchParams.get('id')
|
||||
if (!traceId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability`
|
||||
break
|
||||
}
|
||||
|
||||
case 'provenance': {
|
||||
const provId = searchParams.get('id')
|
||||
if (!provId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
|
||||
break
|
||||
}
|
||||
|
||||
case 'atomic-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
|
||||
break
|
||||
|
||||
case 'similar': {
|
||||
const simControlId = searchParams.get('id')
|
||||
if (!simControlId) {
|
||||
@@ -113,6 +144,23 @@ export async function GET(request: NextRequest) {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
||||
break
|
||||
|
||||
case 'v1-matches': {
|
||||
const matchId = searchParams.get('id')
|
||||
if (!matchId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
|
||||
break
|
||||
}
|
||||
|
||||
case 'v1-enrichment-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
|
||||
break
|
||||
|
||||
case 'obligation-dedup-stats':
|
||||
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
|
||||
break
|
||||
|
||||
case 'controls-customer': {
|
||||
const custSeverity = searchParams.get('severity')
|
||||
const custDomain = searchParams.get('domain')
|
||||
@@ -179,6 +227,16 @@ export async function POST(request: NextRequest) {
|
||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||
} else if (endpoint === 'enrich-v1-matches') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '100'
|
||||
const enrichOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
|
||||
} else if (endpoint === 'obligation-dedup') {
|
||||
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||
const batchSize = searchParams.get('batch_size') ?? '0'
|
||||
const dedupOffset = searchParams.get('offset') ?? '0'
|
||||
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
|
||||
} else if (endpoint === 'similarity-check') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Tenant-ID': tenantID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||
const body = await request.json()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest
|
||||
export const POST = proxyRequest
|
||||
export const DELETE = proxyRequest
|
||||
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
||||
* Returns the decision tree definition (questions, structure)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
||||
headers: { 'X-Tenant-ID': tenantID },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Decision tree GET error:', errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,71 @@ function AdvisoryBoardPageInner() {
|
||||
purposes: [] as string[],
|
||||
// Automation (single-select tile)
|
||||
automation: '' as string,
|
||||
// BetrVG / works council
|
||||
employee_monitoring: false,
|
||||
hr_decision_support: false,
|
||||
works_council_consulted: false,
|
||||
// Domain-specific contexts (Annex III)
|
||||
hr_automated_screening: false,
|
||||
hr_automated_rejection: false,
|
||||
hr_candidate_ranking: false,
|
||||
hr_bias_audits: false,
|
||||
hr_agg_visible: false,
|
||||
hr_human_review: false,
|
||||
hr_performance_eval: false,
|
||||
edu_grade_influence: false,
|
||||
edu_exam_evaluation: false,
|
||||
edu_student_selection: false,
|
||||
edu_minors: false,
|
||||
edu_teacher_review: false,
|
||||
hc_diagnosis: false,
|
||||
hc_treatment: false,
|
||||
hc_triage: false,
|
||||
hc_patient_data: false,
|
||||
hc_medical_device: false,
|
||||
hc_clinical_validation: false,
|
||||
// Legal
|
||||
leg_legal_advice: false, leg_court_prediction: false, leg_client_confidential: false,
|
||||
// Public Sector
|
||||
pub_admin_decision: false, pub_benefit_allocation: false, pub_transparency: false,
|
||||
// Critical Infrastructure
|
||||
crit_grid_control: false, crit_safety_critical: false, crit_redundancy: false,
|
||||
// Automotive
|
||||
auto_autonomous: false, auto_safety: false, auto_functional_safety: false,
|
||||
// Retail
|
||||
ret_pricing: false, ret_profiling: false, ret_credit_scoring: false, ret_dark_patterns: false,
|
||||
// IT Security
|
||||
its_surveillance: false, its_threat_detection: false, its_data_retention: false,
|
||||
// Logistics
|
||||
log_driver_tracking: false, log_workload_scoring: false,
|
||||
// Construction
|
||||
con_tenant_screening: false, con_worker_safety: false,
|
||||
// Marketing
|
||||
mkt_deepfake: false, mkt_minors: false, mkt_targeting: false, mkt_labeled: false,
|
||||
// Manufacturing
|
||||
mfg_machine_safety: false, mfg_ce_required: false, mfg_validated: false,
|
||||
// Agriculture
|
||||
agr_pesticide: false, agr_animal_welfare: false, agr_environmental: false,
|
||||
// Social Services
|
||||
soc_vulnerable: false, soc_benefit: false, soc_case_mgmt: false,
|
||||
// Hospitality
|
||||
hos_guest_profiling: false, hos_dynamic_pricing: false, hos_review_manipulation: false,
|
||||
// Insurance
|
||||
ins_risk_class: false, ins_claims: false, ins_premium: false, ins_fraud: false,
|
||||
// Investment
|
||||
inv_algo_trading: false, inv_advice: false, inv_robo: false,
|
||||
// Defense
|
||||
def_dual_use: false, def_export: false, def_classified: false,
|
||||
// Supply Chain
|
||||
sch_supplier: false, sch_human_rights: false, sch_environmental: false,
|
||||
// Facility
|
||||
fac_access: false, fac_occupancy: false, fac_energy: false,
|
||||
// Sports
|
||||
spo_athlete: false, spo_fan: false, spo_doping: false,
|
||||
// Finance / Banking
|
||||
fin_credit_scoring: false, fin_aml_kyc: false, fin_algo_decisions: false, fin_customer_profiling: false,
|
||||
// General
|
||||
gen_affects_people: false, gen_automated_decisions: false, gen_sensitive_data: false,
|
||||
// Hosting (single-select tile)
|
||||
hosting_provider: '' as string,
|
||||
hosting_region: '' as string,
|
||||
@@ -420,7 +485,131 @@ function AdvisoryBoardPageInner() {
|
||||
retention_purpose: form.retention_purpose,
|
||||
contracts_list: form.contracts,
|
||||
subprocessors: form.subprocessors,
|
||||
employee_monitoring: form.employee_monitoring,
|
||||
hr_decision_support: form.hr_decision_support,
|
||||
works_council_consulted: form.works_council_consulted,
|
||||
// Domain-specific contexts
|
||||
hr_context: ['hr', 'recruiting'].includes(form.domain) ? {
|
||||
automated_screening: form.hr_automated_screening,
|
||||
automated_rejection: form.hr_automated_rejection,
|
||||
candidate_ranking: form.hr_candidate_ranking,
|
||||
bias_audits_done: form.hr_bias_audits,
|
||||
agg_categories_visible: form.hr_agg_visible,
|
||||
human_review_enforced: form.hr_human_review,
|
||||
performance_evaluation: form.hr_performance_eval,
|
||||
} : undefined,
|
||||
education_context: ['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) ? {
|
||||
grade_influence: form.edu_grade_influence,
|
||||
exam_evaluation: form.edu_exam_evaluation,
|
||||
student_selection: form.edu_student_selection,
|
||||
minors_involved: form.edu_minors,
|
||||
teacher_review_required: form.edu_teacher_review,
|
||||
} : undefined,
|
||||
healthcare_context: ['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) ? {
|
||||
diagnosis_support: form.hc_diagnosis,
|
||||
treatment_recommendation: form.hc_treatment,
|
||||
triage_decision: form.hc_triage,
|
||||
patient_data_processed: form.hc_patient_data,
|
||||
medical_device: form.hc_medical_device,
|
||||
clinical_validation: form.hc_clinical_validation,
|
||||
} : undefined,
|
||||
legal_context: ['legal', 'consulting', 'tax_advisory'].includes(form.domain) ? {
|
||||
legal_advice: form.leg_legal_advice,
|
||||
court_prediction: form.leg_court_prediction,
|
||||
client_confidential: form.leg_client_confidential,
|
||||
} : undefined,
|
||||
public_sector_context: ['public_sector', 'defense', 'justice'].includes(form.domain) ? {
|
||||
admin_decision: form.pub_admin_decision,
|
||||
benefit_allocation: form.pub_benefit_allocation,
|
||||
transparency_ensured: form.pub_transparency,
|
||||
} : undefined,
|
||||
critical_infra_context: ['energy', 'utilities', 'oil_gas'].includes(form.domain) ? {
|
||||
grid_control: form.crit_grid_control,
|
||||
safety_critical: form.crit_safety_critical,
|
||||
redundancy_exists: form.crit_redundancy,
|
||||
} : undefined,
|
||||
automotive_context: ['automotive', 'aerospace'].includes(form.domain) ? {
|
||||
autonomous_driving: form.auto_autonomous,
|
||||
safety_relevant: form.auto_safety,
|
||||
functional_safety: form.auto_functional_safety,
|
||||
} : undefined,
|
||||
retail_context: ['retail', 'ecommerce', 'wholesale'].includes(form.domain) ? {
|
||||
pricing_personalized: form.ret_pricing,
|
||||
credit_scoring: form.ret_credit_scoring,
|
||||
dark_patterns: form.ret_dark_patterns,
|
||||
} : undefined,
|
||||
it_security_context: ['it_services', 'cybersecurity', 'telecom'].includes(form.domain) ? {
|
||||
employee_surveillance: form.its_surveillance,
|
||||
threat_detection: form.its_threat_detection,
|
||||
data_retention_logs: form.its_data_retention,
|
||||
} : undefined,
|
||||
logistics_context: ['logistics'].includes(form.domain) ? {
|
||||
driver_tracking: form.log_driver_tracking,
|
||||
workload_scoring: form.log_workload_scoring,
|
||||
} : undefined,
|
||||
construction_context: ['construction', 'real_estate', 'facility_management'].includes(form.domain) ? {
|
||||
tenant_screening: form.con_tenant_screening,
|
||||
worker_safety: form.con_worker_safety,
|
||||
} : undefined,
|
||||
marketing_context: ['marketing', 'media', 'entertainment'].includes(form.domain) ? {
|
||||
deepfake_content: form.mkt_deepfake,
|
||||
behavioral_targeting: form.mkt_targeting,
|
||||
minors_targeted: form.mkt_minors,
|
||||
ai_content_labeled: form.mkt_labeled,
|
||||
} : undefined,
|
||||
manufacturing_context: ['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage'].includes(form.domain) ? {
|
||||
machine_safety: form.mfg_machine_safety,
|
||||
ce_marking_required: form.mfg_ce_required,
|
||||
safety_validated: form.mfg_validated,
|
||||
} : undefined,
|
||||
agriculture_context: ['agriculture', 'forestry', 'fishing'].includes(form.domain) ? {
|
||||
pesticide_ai: form.agr_pesticide,
|
||||
animal_welfare: form.agr_animal_welfare,
|
||||
environmental_data: form.agr_environmental,
|
||||
} : undefined,
|
||||
social_services_context: ['social_services', 'nonprofit'].includes(form.domain) ? {
|
||||
vulnerable_groups: form.soc_vulnerable,
|
||||
benefit_decision: form.soc_benefit,
|
||||
case_management: form.soc_case_mgmt,
|
||||
} : undefined,
|
||||
hospitality_context: ['hospitality', 'tourism'].includes(form.domain) ? {
|
||||
guest_profiling: form.hos_guest_profiling,
|
||||
dynamic_pricing: form.hos_dynamic_pricing,
|
||||
review_manipulation: form.hos_review_manipulation,
|
||||
} : undefined,
|
||||
insurance_context: ['insurance'].includes(form.domain) ? {
|
||||
risk_classification: form.ins_risk_class,
|
||||
claims_automation: form.ins_claims,
|
||||
premium_calculation: form.ins_premium,
|
||||
fraud_detection: form.ins_fraud,
|
||||
} : undefined,
|
||||
investment_context: ['investment'].includes(form.domain) ? {
|
||||
algo_trading: form.inv_algo_trading,
|
||||
investment_advice: form.inv_advice,
|
||||
robo_advisor: form.inv_robo,
|
||||
} : undefined,
|
||||
defense_context: ['defense'].includes(form.domain) ? {
|
||||
dual_use: form.def_dual_use,
|
||||
export_controlled: form.def_export,
|
||||
classified_data: form.def_classified,
|
||||
} : undefined,
|
||||
supply_chain_context: ['textiles', 'packaging'].includes(form.domain) ? {
|
||||
supplier_monitoring: form.sch_supplier,
|
||||
human_rights_check: form.sch_human_rights,
|
||||
environmental_impact: form.sch_environmental,
|
||||
} : undefined,
|
||||
facility_context: ['facility_management'].includes(form.domain) ? {
|
||||
access_control_ai: form.fac_access,
|
||||
occupancy_tracking: form.fac_occupancy,
|
||||
energy_optimization: form.fac_energy,
|
||||
} : undefined,
|
||||
sports_context: ['sports'].includes(form.domain) ? {
|
||||
athlete_tracking: form.spo_athlete,
|
||||
fan_profiling: form.spo_fan,
|
||||
} : undefined,
|
||||
store_raw_text: true,
|
||||
// Finance/Banking and General don't need separate context structs —
|
||||
// their fields are evaluated via existing FinancialContext or generic rules
|
||||
}
|
||||
|
||||
const url = isEditMode
|
||||
@@ -777,6 +966,567 @@ function AdvisoryBoardPageInner() {
|
||||
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BetrVG Section */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Betriebsrat & Beschaeftigtendaten</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Relevant fuer deutsche Unternehmen mit Betriebsrat (§87 Abs.1 Nr.6 BetrVG).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.employee_monitoring}
|
||||
onChange={(e) => updateForm({ employee_monitoring: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">System kann Verhalten/Leistung ueberwachen</span>
|
||||
<p className="text-xs text-gray-500">Nutzungslogs, Produktivitaetskennzahlen, Kommunikationsanalyse</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.hr_decision_support}
|
||||
onChange={(e) => updateForm({ hr_decision_support: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">System unterstuetzt HR-Entscheidungen</span>
|
||||
<p className="text-xs text-gray-500">Recruiting, Bewertung, Befoerderung, Kuendigung</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.works_council_consulted}
|
||||
onChange={(e) => updateForm({ works_council_consulted: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">Betriebsrat wurde konsultiert</span>
|
||||
<p className="text-xs text-gray-500">Betriebsvereinbarung liegt vor oder ist in Verhandlung</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain-specific questions — HR/Recruiting */}
|
||||
{['hr', 'recruiting'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">HR & Recruiting — Hochrisiko-Pruefung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 4 + AGG — Pflichtfragen bei KI im Personalbereich.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hr_automated_screening} onChange={(e) => updateForm({ hr_automated_screening: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Bewerber werden automatisch vorsortiert/gerankt</span><p className="text-xs text-gray-500">CV-Screening, Score-basierte Vorauswahl</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hr_automated_rejection} onChange={(e) => updateForm({ hr_automated_rejection: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Absagen werden automatisch versendet</span><p className="text-xs text-red-700">Art. 22 DSGVO: Vollautomatische Absagen grundsaetzlich unzulaessig!</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hr_agg_visible} onChange={(e) => updateForm({ hr_agg_visible: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">System kann AGG-Merkmale erkennen (Name, Foto, Alter)</span><p className="text-xs text-gray-500">Proxy-Diskriminierung: Name→Herkunft, Foto→Geschlecht</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hr_performance_eval} onChange={(e) => updateForm({ hr_performance_eval: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">System bewertet Mitarbeiterleistung</span><p className="text-xs text-gray-500">Performance Reviews, KPI-Tracking</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hr_bias_audits} onChange={(e) => updateForm({ hr_bias_audits: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Regelmaessige Bias-Audits durchgefuehrt</span><p className="text-xs text-green-700">Analyse nach Geschlecht, Alter, Herkunft</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hr_human_review} onChange={(e) => updateForm({ hr_human_review: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Mensch prueft jede KI-Empfehlung</span><p className="text-xs text-green-700">Kein Rubber Stamping — echte Pruefung</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain-specific questions — Education */}
|
||||
{['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Bildung — Hochrisiko-Pruefung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 3 — bei KI in Bildung und Ausbildung.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.edu_grade_influence} onChange={(e) => updateForm({ edu_grade_influence: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Noten oder Bewertungen</span><p className="text-xs text-gray-500">Notenvorschlaege, Bewertungsunterstuetzung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.edu_exam_evaluation} onChange={(e) => updateForm({ edu_exam_evaluation: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI bewertet Pruefungen/Klausuren</span><p className="text-xs text-gray-500">Automatische Korrektur, Bewertungsvorschlaege</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.edu_student_selection} onChange={(e) => updateForm({ edu_student_selection: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Zugang zu Bildungsangeboten</span><p className="text-xs text-gray-500">Zulassung, Kursempfehlungen, Einstufung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.edu_minors} onChange={(e) => updateForm({ edu_minors: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Minderjaehrige sind betroffen</span><p className="text-xs text-red-700">Besonderer Schutz (Art. 24 EU-Grundrechtecharta)</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.edu_teacher_review} onChange={(e) => updateForm({ edu_teacher_review: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Lehrkraft prueft jedes KI-Ergebnis</span><p className="text-xs text-green-700">Human Oversight vor Mitteilung an Schueler</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain-specific questions — Healthcare */}
|
||||
{['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Gesundheitswesen — Hochrisiko-Pruefung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 5 + MDR (EU) 2017/745.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hc_diagnosis} onChange={(e) => updateForm({ hc_diagnosis: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI unterstuetzt Diagnosen</span><p className="text-xs text-gray-500">Diagnosevorschlaege, Bildgebungsauswertung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hc_treatment} onChange={(e) => updateForm({ hc_treatment: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI empfiehlt Behandlungen</span><p className="text-xs text-gray-500">Therapievorschlaege, Medikation</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hc_triage} onChange={(e) => updateForm({ hc_triage: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">KI priorisiert Patienten (Triage)</span><p className="text-xs text-red-700">Lebenskritisch — erhoehte Anforderungen</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hc_patient_data} onChange={(e) => updateForm({ hc_patient_data: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Gesundheitsdaten verarbeitet</span><p className="text-xs text-gray-500">Art. 9 DSGVO — besondere Kategorie</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hc_medical_device} onChange={(e) => updateForm({ hc_medical_device: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">System ist Medizinprodukt (MDR)</span><p className="text-xs text-gray-500">MDR (EU) 2017/745 — Zertifizierung erforderlich</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hc_clinical_validation} onChange={(e) => updateForm({ hc_clinical_validation: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Klinisch validiert</span><p className="text-xs text-green-700">System wurde in klinischer Studie geprueft</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legal / Justice */}
|
||||
{['legal', 'consulting', 'tax_advisory'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Recht & Beratung — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 8 — KI in Rechtspflege und Demokratie.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.leg_legal_advice} onChange={(e) => updateForm({ leg_legal_advice: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI gibt Rechtsberatung oder rechtliche Empfehlungen</span><p className="text-xs text-gray-500">Vertragsanalyse, rechtliche Einschaetzungen, Compliance-Checks</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.leg_court_prediction} onChange={(e) => updateForm({ leg_court_prediction: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI prognostiziert Verfahrensausgaenge</span><p className="text-xs text-gray-500">Urteilsprognosen, Risikoeinschaetzung von Rechtsstreitigkeiten</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.leg_client_confidential} onChange={(e) => updateForm({ leg_client_confidential: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Mandantengeheimnis betroffen</span><p className="text-xs text-gray-500">Vertrauliche Mandantendaten werden durch KI verarbeitet (§ 203 StGB)</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Public Sector */}
|
||||
{['public_sector', 'defense', 'justice'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Oeffentlicher Sektor — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Art. 27 AI Act — FRIA-Pflicht fuer oeffentliche Stellen.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.pub_admin_decision} onChange={(e) => updateForm({ pub_admin_decision: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">KI beeinflusst Verwaltungsentscheidungen</span><p className="text-xs text-red-700">Bescheide, Bewilligungen, Genehmigungen — FRIA erforderlich</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.pub_benefit_allocation} onChange={(e) => updateForm({ pub_benefit_allocation: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI verteilt Leistungen oder Foerderung</span><p className="text-xs text-gray-500">Sozialleistungen, Subventionen, Zuteilungen</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.pub_transparency} onChange={(e) => updateForm({ pub_transparency: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Transparenz gegenueber Buergern sichergestellt</span><p className="text-xs text-green-700">Buerger werden ueber KI-Nutzung informiert</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Critical Infrastructure */}
|
||||
{['energy', 'utilities', 'oil_gas'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Kritische Infrastruktur — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 2 + NIS2.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.crit_grid_control} onChange={(e) => updateForm({ crit_grid_control: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI steuert Netz oder Infrastruktur</span><p className="text-xs text-gray-500">Stromnetz, Wasserversorgung, Gasverteilung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.crit_safety_critical} onChange={(e) => updateForm({ crit_safety_critical: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Sicherheitskritische Steuerung</span><p className="text-xs text-red-700">Fehler koennen Menschenleben gefaehrden</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.crit_redundancy} onChange={(e) => updateForm({ crit_redundancy: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Redundante Systeme vorhanden</span><p className="text-xs text-green-700">Fallback bei KI-Ausfall sichergestellt</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Automotive / Aerospace */}
|
||||
{['automotive', 'aerospace'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Automotive / Aerospace — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Safety-critical AI — Typgenehmigung + Functional Safety.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.auto_autonomous} onChange={(e) => updateForm({ auto_autonomous: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Autonomes Fahren / ADAS</span><p className="text-xs text-red-700">Hochrisiko — erfordert Typgenehmigung und extensive Validierung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.auto_safety} onChange={(e) => updateForm({ auto_safety: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Sicherheitsrelevante Funktion</span><p className="text-xs text-gray-500">Bremsen, Lenkung, Kollisionsvermeidung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.auto_functional_safety} onChange={(e) => updateForm({ auto_functional_safety: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">ISO 26262 Functional Safety beruecksichtigt</span><p className="text-xs text-green-700">ASIL-Einstufung und Sicherheitsvalidierung durchgefuehrt</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retail / E-Commerce */}
|
||||
{['retail', 'ecommerce', 'wholesale'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Handel & E-Commerce — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">DSA, Verbraucherrecht, DSGVO Art. 22.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ret_pricing} onChange={(e) => updateForm({ ret_pricing: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Personalisierte Preisgestaltung</span><p className="text-xs text-gray-500">Individuelle Preise basierend auf Nutzerprofil</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ret_credit_scoring} onChange={(e) => updateForm({ ret_credit_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Bonitaetspruefung bei Kauf auf Rechnung</span><p className="text-xs text-gray-500">Kredit-Scoring beeinflusst Zugang zu Zahlungsarten</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ret_dark_patterns} onChange={(e) => updateForm({ ret_dark_patterns: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Manipulative UI-Muster moeglich (Dark Patterns)</span><p className="text-xs text-gray-500">Kuenstliche Verknappung, Social Proof, versteckte Kosten</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IT / Cybersecurity / Telecom */}
|
||||
{['it_services', 'cybersecurity', 'telecom'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">IT & Cybersecurity — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">NIS2, DSGVO, BetrVG §87.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.its_surveillance} onChange={(e) => updateForm({ its_surveillance: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Mitarbeiterueberwachung (SIEM, DLP, UBA)</span><p className="text-xs text-gray-500">User Behavior Analytics, Data Loss Prevention mit Personenbezug</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.its_threat_detection} onChange={(e) => updateForm({ its_threat_detection: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI-gestuetzte Bedrohungserkennung</span><p className="text-xs text-gray-500">Anomalie-Erkennung, Intrusion Detection</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.its_data_retention} onChange={(e) => updateForm({ its_data_retention: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Umfangreiche Log-Speicherung</span><p className="text-xs text-gray-500">Security-Logs mit Personenbezug werden langfristig gespeichert</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logistics */}
|
||||
{['logistics'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Logistik — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">BetrVG §87, DSGVO — Worker Tracking.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.log_driver_tracking} onChange={(e) => updateForm({ log_driver_tracking: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Fahrer-/Kurier-Tracking (GPS)</span><p className="text-xs text-gray-500">Standortverfolgung von Mitarbeitern</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.log_workload_scoring} onChange={(e) => updateForm({ log_workload_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Leistungsbewertung von Lager-/Liefermitarbeitern</span><p className="text-xs text-gray-500">Picks/Stunde, Liefergeschwindigkeit, Performance-Scores</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Construction / Real Estate */}
|
||||
{['construction', 'real_estate', 'facility_management'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Bau & Immobilien — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">AGG, DSGVO, Arbeitsschutz.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.con_tenant_screening} onChange={(e) => updateForm({ con_tenant_screening: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI-gestuetzte Mieterauswahl</span><p className="text-xs text-gray-500">Bonitaetspruefung, Bewerber-Ranking fuer Wohnungen</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.con_worker_safety} onChange={(e) => updateForm({ con_worker_safety: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI-Arbeitsschutzueberwachung auf Baustellen</span><p className="text-xs text-gray-500">Kamera-basierte Sicherheitsueberwachung, Helm-Erkennung</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Marketing / Media */}
|
||||
{['marketing', 'media', 'entertainment'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Marketing & Medien — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Art. 50 AI Act (Deepfakes), DSA, DSGVO.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mkt_deepfake} onChange={(e) => updateForm({ mkt_deepfake: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Synthetische Inhalte (Deepfakes)</span><p className="text-xs text-red-700">KI-generierte Bilder, Videos oder Stimmen — Kennzeichnungspflicht!</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mkt_targeting} onChange={(e) => updateForm({ mkt_targeting: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Verhaltensbasiertes Targeting</span><p className="text-xs text-gray-500">Personalisierte Werbung basierend auf Nutzerverhalten</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mkt_minors} onChange={(e) => updateForm({ mkt_minors: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Minderjaehrige als Zielgruppe</span><p className="text-xs text-red-700">Besonderer Schutz — DSA Art. 28 verbietet Profiling Minderjaehriger</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mkt_labeled} onChange={(e) => updateForm({ mkt_labeled: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">KI-Inhalte werden als solche gekennzeichnet</span><p className="text-xs text-green-700">Art. 50 AI Act: Pflicht zur Kennzeichnung synthetischer Inhalte</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manufacturing */}
|
||||
{['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage', 'textiles', 'packaging'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Fertigung — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Maschinenverordnung (EU) 2023/1230, CE-Kennzeichnung.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mfg_machine_safety} onChange={(e) => updateForm({ mfg_machine_safety: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">KI in Maschinensicherheit</span><p className="text-xs text-red-700">Sicherheitsrelevante Steuerung — Validierung erforderlich</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mfg_ce_required} onChange={(e) => updateForm({ mfg_ce_required: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">CE-Kennzeichnung erforderlich</span><p className="text-xs text-gray-500">Maschinenverordnung (EU) 2023/1230</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.mfg_validated} onChange={(e) => updateForm({ mfg_validated: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||
<div><span className="text-sm font-medium text-green-900">Sicherheitsvalidierung durchgefuehrt</span><p className="text-xs text-green-700">Konformitaetsbewertung nach Maschinenverordnung abgeschlossen</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agriculture */}
|
||||
{['agriculture', 'forestry', 'fishing'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Landwirtschaft — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.agr_pesticide} onChange={(e) => updateForm({ agr_pesticide: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI steuert Pestizideinsatz</span><p className="text-xs text-gray-500">Precision Farming, automatisierte Ausbringung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.agr_animal_welfare} onChange={(e) => updateForm({ agr_animal_welfare: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Tierhaltungsentscheidungen</span><p className="text-xs text-gray-500">Fuetterung, Gesundheit, Stallmanagement</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.agr_environmental} onChange={(e) => updateForm({ agr_environmental: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Umweltdaten werden verarbeitet</span><p className="text-xs text-gray-500">Boden, Wasser, Emissionen</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Social Services */}
|
||||
{['social_services', 'nonprofit'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Soziales — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.soc_vulnerable} onChange={(e) => updateForm({ soc_vulnerable: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Schutzbeduerftiger Personenkreis betroffen</span><p className="text-xs text-red-700">Kinder, Senioren, Gefluechtete, Menschen mit Behinderung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.soc_benefit} onChange={(e) => updateForm({ soc_benefit: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Leistungszuteilung</span><p className="text-xs text-gray-500">Sozialleistungen, Hilfsangebote, Foerderung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.soc_case_mgmt} onChange={(e) => updateForm({ soc_case_mgmt: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI in Fallmanagement</span><p className="text-xs text-gray-500">Priorisierung, Zuordnung, Verlaufsprognose</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hospitality / Tourism */}
|
||||
{['hospitality', 'tourism'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Tourismus & Gastronomie — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hos_guest_profiling} onChange={(e) => updateForm({ hos_guest_profiling: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Gaeste-Profilbildung</span><p className="text-xs text-gray-500">Praeferenzen, Buchungsverhalten, Segmentierung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hos_dynamic_pricing} onChange={(e) => updateForm({ hos_dynamic_pricing: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Dynamische Preisgestaltung</span><p className="text-xs text-gray-500">Personalisierte Zimmer-/Flugreise</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.hos_review_manipulation} onChange={(e) => updateForm({ hos_review_manipulation: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">KI manipuliert oder generiert Bewertungen</span><p className="text-xs text-red-700">Fake Reviews sind unzulaessig (UWG, DSA)</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Insurance */}
|
||||
{['insurance'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Versicherung — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ins_premium} onChange={(e) => updateForm({ ins_premium: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI berechnet individuelle Praemien</span><p className="text-xs text-gray-500">Risikoadjustierte Preisgestaltung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ins_claims} onChange={(e) => updateForm({ ins_claims: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Automatisierte Schadenbearbeitung</span><p className="text-xs text-gray-500">KI entscheidet ueber Schadenregulierung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ins_fraud} onChange={(e) => updateForm({ ins_fraud: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI-Betrugserkennung</span><p className="text-xs text-gray-500">Automatische Verdachtsfallerkennung</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Investment */}
|
||||
{['investment'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Investment — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.inv_algo_trading} onChange={(e) => updateForm({ inv_algo_trading: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Algorithmischer Handel</span><p className="text-xs text-gray-500">Automated Trading, HFT — MiFID II relevant</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.inv_robo} onChange={(e) => updateForm({ inv_robo: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Robo Advisor / KI-Anlageberatung</span><p className="text-xs text-gray-500">Automatisierte Vermoegensberatung — WpHG-Pflichten</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Defense */}
|
||||
{['defense'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Verteidigung — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||
<input type="checkbox" checked={form.def_dual_use} onChange={(e) => updateForm({ def_dual_use: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||
<div><span className="text-sm font-medium text-red-900">Dual-Use KI-Technologie</span><p className="text-xs text-red-700">Exportkontrolle (EU VO 2021/821) beachten</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.def_classified} onChange={(e) => updateForm({ def_classified: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Verschlusssachen werden verarbeitet</span><p className="text-xs text-gray-500">VS-NfD oder hoeher — besondere Schutzmassnahmen</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supply Chain (Textiles, Packaging) */}
|
||||
{['textiles', 'packaging'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Lieferkette — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">LkSG — Lieferkettensorgfaltspflichtengesetz.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.sch_supplier} onChange={(e) => updateForm({ sch_supplier: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI ueberwacht Lieferanten</span><p className="text-xs text-gray-500">Lieferantenbewertung, Risikoanalyse</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.sch_human_rights} onChange={(e) => updateForm({ sch_human_rights: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI prueft Menschenrechte in Lieferkette</span><p className="text-xs text-gray-500">LkSG-Sorgfaltspflichten, Kinderarbeit, Zwangsarbeit</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sports */}
|
||||
{['sports'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Sport — Compliance-Fragen</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.spo_athlete} onChange={(e) => updateForm({ spo_athlete: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Athleten-Performance-Tracking</span><p className="text-xs text-gray-500">GPS, Biometrie, Leistungsdaten</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.spo_fan} onChange={(e) => updateForm({ spo_fan: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Fan-/Zuschauer-Profilbildung</span><p className="text-xs text-gray-500">Ticketing, Merchandising, Stadion-Tracking</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Finance / Banking */}
|
||||
{['finance', 'banking'].includes(form.domain) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Finanzdienstleistungen — Compliance-Fragen</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">DORA, MaRisk, BAIT, AI Act Annex III Nr. 5.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fin_credit_scoring} onChange={(e) => updateForm({ fin_credit_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">KI-gestuetztes Kredit-Scoring</span><p className="text-xs text-gray-500">Bonitaetsbewertung, Kreditwuerdigkeitspruefung — Art. 22 DSGVO + AGG</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fin_aml_kyc} onChange={(e) => updateForm({ fin_aml_kyc: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">AML/KYC Automatisierung</span><p className="text-xs text-gray-500">Geldwaeschebekacmpfung, Kundenidentifizierung durch KI</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fin_algo_decisions} onChange={(e) => updateForm({ fin_algo_decisions: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Automatisierte Finanzentscheidungen</span><p className="text-xs text-gray-500">Kreditvergabe, Kontosperrung, Limitaenderungen</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fin_customer_profiling} onChange={(e) => updateForm({ fin_customer_profiling: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Kunden-Profilbildung / Segmentierung</span><p className="text-xs text-gray-500">Risikoklassifikation, Produkt-Empfehlungen</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General — universal AI governance questions */}
|
||||
{form.domain === 'general' && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">Allgemeine KI-Governance</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Grundlegende Compliance-Fragen fuer jeden KI-Einsatz.</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.gen_affects_people} onChange={(e) => updateForm({ gen_affects_people: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">System hat Auswirkungen auf natuerliche Personen</span><p className="text-xs text-gray-500">Entscheidungen, Empfehlungen oder Bewertungen betreffen Menschen direkt</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.gen_automated_decisions} onChange={(e) => updateForm({ gen_automated_decisions: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Automatisierte Entscheidungen werden getroffen</span><p className="text-xs text-gray-500">KI trifft oder beeinflusst Entscheidungen ohne menschliche Pruefung</p></div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.gen_sensitive_data} onChange={(e) => updateForm({ gen_sensitive_data: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<div><span className="text-sm font-medium text-gray-900">Sensible oder vertrauliche Daten verarbeitet</span><p className="text-xs text-gray-500">Geschaeftsgeheimnisse, personenbezogene Daten, vertrauliche Informationen</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -21,6 +22,8 @@ interface AISystem {
|
||||
assessmentResult: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
@@ -306,12 +309,178 @@ function AISystemCard({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SAVED RESULTS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface SavedResult {
|
||||
id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
high_risk_result: string
|
||||
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||
combined_obligations: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function SavedResultsTab() {
|
||||
const [results, setResults] = useState<SavedResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(data.results || [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Ergebnis wirklich löschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setResults(prev => prev.filter(r => r.id !== id))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
unacceptable: 'Unzulässig',
|
||||
high_risk: 'Hochrisiko',
|
||||
limited_risk: 'Begrenztes Risiko',
|
||||
minimal_risk: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
unacceptable: 'bg-red-100 text-red-700',
|
||||
high_risk: 'bg-orange-100 text-orange-700',
|
||||
limited_risk: 'bg-yellow-100 text-yellow-700',
|
||||
minimal_risk: 'bg-green-100 text-green-700',
|
||||
not_applicable: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const gpaiLabels: Record<string, string> = {
|
||||
none: 'Kein GPAI',
|
||||
standard: 'GPAI Standard',
|
||||
systemic: 'GPAI Systemisch',
|
||||
}
|
||||
|
||||
const gpaiColors: Record<string, string> = {
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
standard: 'bg-blue-100 text-blue-700',
|
||||
systemic: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||
{r.system_description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||
</span>
|
||||
{r.gpai_result?.is_systemic_risk && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TABS
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
label: 'Entscheidungsbaum',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: 'Ergebnisse',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -354,7 +523,6 @@ export default function AIActPage() {
|
||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
setError(null)
|
||||
if (editingSystem) {
|
||||
// Edit existing system via PUT
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -380,14 +548,12 @@ export default function AIActPage() {
|
||||
setError('Speichern fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: update locally
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
// Create new system via POST
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||
method: 'POST',
|
||||
@@ -415,7 +581,6 @@ export default function AIActPage() {
|
||||
setError('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: add locally
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
@@ -503,6 +668,7 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
@@ -512,8 +678,27 @@ export default function AIActPage() {
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
@@ -522,6 +707,9 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
@@ -607,6 +795,18 @@ export default function AIActPage() {
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface Registration {
|
||||
id: string
|
||||
system_name: string
|
||||
system_version: string
|
||||
risk_classification: string
|
||||
gpai_classification: string
|
||||
registration_status: string
|
||||
eu_database_id: string
|
||||
provider_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||
}
|
||||
|
||||
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
system_name: '',
|
||||
system_version: '1.0',
|
||||
system_description: '',
|
||||
intended_purpose: '',
|
||||
provider_name: '',
|
||||
provider_legal_form: '',
|
||||
provider_address: '',
|
||||
provider_country: 'DE',
|
||||
eu_representative_name: '',
|
||||
eu_representative_contact: '',
|
||||
risk_classification: 'not_classified',
|
||||
annex_iii_category: '',
|
||||
gpai_classification: 'none',
|
||||
conformity_assessment_type: 'internal',
|
||||
notified_body_name: '',
|
||||
notified_body_id: '',
|
||||
ce_marking: false,
|
||||
training_data_summary: '',
|
||||
}
|
||||
|
||||
export default function AIRegistrationPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { loadRegistrations() }, [])
|
||||
|
||||
async function loadRegistrations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setRegistrations(data.registrations || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowWizard(false)
|
||||
setForm({ ...INITIAL_FORM })
|
||||
setWizardStep(1)
|
||||
loadRegistrations()
|
||||
} else {
|
||||
const data = await resp.json()
|
||||
setError(data.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||
if (resp.ok) {
|
||||
const reg = await resp.json()
|
||||
// Build export JSON client-side
|
||||
const exportData = {
|
||||
schema_version: '1.0',
|
||||
submission_type: 'ai_system_registration',
|
||||
regulation: 'EU AI Act (EU) 2024/1689',
|
||||
article: 'Art. 49',
|
||||
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
loadRegistrations()
|
||||
} catch {
|
||||
setError('Status-Aenderung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
+ Neue Registrierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||
const count = registrations.filter(r => r.registration_status === status).length
|
||||
const style = STATUS_STYLES[status]
|
||||
return (
|
||||
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{style.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Registrations List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map(reg => {
|
||||
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||
return (
|
||||
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||
{reg.gpai_classification !== 'none' && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
JSON Export
|
||||
</button>
|
||||
{reg.registration_status === 'draft' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Bereit markieren
|
||||
</button>
|
||||
)}
|
||||
{reg.registration_status === 'ready' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Als eingereicht markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Modal */}
|
||||
{showWizard && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-1">
|
||||
{STEPS.map(step => (
|
||||
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||
}`}>
|
||||
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Provider */}
|
||||
{wizardStep === 1 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Oesterreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="OTHER">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: System */}
|
||||
{wizardStep === 2 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 3: Classification */}
|
||||
{wizardStep === 3 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal_risk">Minimal Risk</option>
|
||||
<option value="limited_risk">Limited Risk</option>
|
||||
<option value="high_risk">High Risk</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.risk_classification === 'high_risk' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="none">Kein GPAI</option>
|
||||
<option value="standard">GPAI (Standard)</option>
|
||||
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Conformity */}
|
||||
{wizardStep === 4 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_required">Nicht erforderlich</option>
|
||||
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.conformity_assessment_type === 'third_party' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body ID</label>
|
||||
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 5: Training Data */}
|
||||
{wizardStep === 5 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 6: Review */}
|
||||
{wizardStep === 6 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||
</div>
|
||||
{form.intended_purpose && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="p-6 border-t flex justify-between">
|
||||
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
{wizardStep < 6 ? (
|
||||
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||
disabled={wizardStep === 2 && !form.system_name}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -160,6 +160,8 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'security_backlog', 'quality_entries',
|
||||
'notfallplan_incidents', 'notfallplan_templates',
|
||||
'data_processing_agreement',
|
||||
'vendor_vendors', 'vendor_contracts', 'vendor_findings',
|
||||
'vendor_control_instances', 'compliance_templates',
|
||||
'compliance_isms_scope', 'compliance_isms_context', 'compliance_isms_policy',
|
||||
'compliance_security_objectives', 'compliance_soa',
|
||||
'compliance_audit_findings', 'compliance_corrective_actions',
|
||||
@@ -178,6 +180,10 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
'CRUD /api/compliance/vvt',
|
||||
'CRUD /api/compliance/loeschfristen',
|
||||
'CRUD /api/compliance/obligations',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/vendors',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/contracts',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/findings',
|
||||
'CRUD /api/sdk/v1/vendor-compliance/control-instances',
|
||||
'CRUD /api/isms/scope',
|
||||
'CRUD /api/isms/policies',
|
||||
'CRUD /api/isms/objectives',
|
||||
|
||||
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Assertion {
|
||||
id: string
|
||||
tenant_id: string | null
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
sentence_text: string
|
||||
sentence_index: number
|
||||
assertion_type: string // 'assertion' | 'fact' | 'rationale'
|
||||
evidence_ids: string[]
|
||||
confidence: number
|
||||
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface AssertionSummary {
|
||||
total_assertions: number
|
||||
total_facts: number
|
||||
total_rationale: number
|
||||
unverified_count: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
pflicht: 'bg-red-100 text-red-700',
|
||||
empfehlung: 'bg-yellow-100 text-yellow-700',
|
||||
kann: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
pflicht: 'Pflicht',
|
||||
empfehlung: 'Empfehlung',
|
||||
kann: 'Kann',
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
assertion: 'bg-orange-100 text-orange-700',
|
||||
fact: 'bg-green-100 text-green-700',
|
||||
rationale: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
assertion: 'Behauptung',
|
||||
fact: 'Fakt',
|
||||
rationale: 'Begruendung',
|
||||
}
|
||||
|
||||
const API_BASE = '/api/sdk/v1/compliance'
|
||||
|
||||
type TabKey = 'overview' | 'list' | 'extract'
|
||||
|
||||
// =============================================================================
|
||||
// ASSERTION CARD
|
||||
// =============================================================================
|
||||
|
||||
function AssertionCard({
|
||||
assertion,
|
||||
onVerify,
|
||||
}: {
|
||||
assertion: Assertion
|
||||
onVerify: (id: string) => void
|
||||
}) {
|
||||
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
|
||||
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
|
||||
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
|
||||
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
|
||||
{tierLabel}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
{assertion.entity_type && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
|
||||
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.confidence > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 leading-relaxed">
|
||||
“{assertion.sentence_text}”
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
|
||||
{assertion.verified_by && (
|
||||
<span className="text-green-600">
|
||||
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
|
||||
</span>
|
||||
)}
|
||||
{assertion.evidence_ids.length > 0 && (
|
||||
<span>
|
||||
{assertion.evidence_ids.length} Evidence verknuepft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{assertion.assertion_type !== 'fact' && (
|
||||
<button
|
||||
onClick={() => onVerify(assertion.id)}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Als Fakt pruefen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AssertionsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview')
|
||||
const [summary, setSummary] = useState<AssertionSummary | null>(null)
|
||||
const [assertions, setAssertions] = useState<Assertion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [filterEntityType, setFilterEntityType] = useState('')
|
||||
const [filterAssertionType, setFilterAssertionType] = useState('')
|
||||
|
||||
// Extract tab
|
||||
const [extractText, setExtractText] = useState('')
|
||||
const [extractEntityType, setExtractEntityType] = useState('control')
|
||||
const [extractEntityId, setExtractEntityId] = useState('')
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
|
||||
|
||||
// Verify dialog
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null)
|
||||
const [verifyEmail, setVerifyEmail] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') loadAssertions()
|
||||
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/summary`)
|
||||
if (res.ok) setSummary(await res.json())
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const loadAssertions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterEntityType) params.set('entity_type', filterEntityType)
|
||||
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
|
||||
params.set('limit', '200')
|
||||
|
||||
const res = await fetch(`${API_BASE}/assertions?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAssertions(data.assertions || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Assertions konnten nicht geladen werden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
|
||||
setExtracting(true)
|
||||
setError(null)
|
||||
setExtractedAssertions([])
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: extractText,
|
||||
entity_type: extractEntityType || 'control',
|
||||
entity_id: extractEntityId || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
const data = await res.json()
|
||||
setExtractedAssertions(data.assertions || [])
|
||||
// Refresh summary
|
||||
loadSummary()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (assertionId: string) => {
|
||||
setVerifyingId(assertionId)
|
||||
}
|
||||
|
||||
const submitVerify = async () => {
|
||||
if (!verifyingId || !verifyEmail.trim()) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
setVerifyingId(null)
|
||||
setVerifyEmail('')
|
||||
loadAssertions()
|
||||
loadSummary()
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
|
||||
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Uebersicht' },
|
||||
{ key: 'list', label: 'Assertion-Liste' },
|
||||
{ key: 'extract', label: 'Extraktion' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-sm border">
|
||||
<div className="flex border-b">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'text-purple-600 border-b-2 border-purple-600'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Uebersicht */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt Assertions</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Verifizierte Fakten</div>
|
||||
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||
<div className="text-sm text-purple-600">Begruendungen</div>
|
||||
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Unverifizizt</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Assertion-Liste */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
|
||||
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
|
||||
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Alle</option>
|
||||
<option value="assertion">Behauptung</option>
|
||||
<option value="fact">Fakt</option>
|
||||
<option value="rationale">Begruendung</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : assertions.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Keine Assertions gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
|
||||
{assertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TAB: Extraktion */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'extract' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
|
||||
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="control">Control</option>
|
||||
<option value="evidence">Evidence</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
<option value="policy">Policy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
|
||||
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
|
||||
placeholder="z.B. GOV-001 oder UUID"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
|
||||
<textarea
|
||||
value={extractText}
|
||||
onChange={e => setExtractText(e.target.value)}
|
||||
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
|
||||
rows={6}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={extracting || !extractText.trim()}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
|
||||
extracting || !extractText.trim()
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{extracting ? 'Extrahiere...' : 'Extrahieren'}
|
||||
</button>
|
||||
|
||||
{/* Extracted results */}
|
||||
{extractedAssertions.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
|
||||
<div className="space-y-3">
|
||||
{extractedAssertions.map(a => (
|
||||
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verify Dialog */}
|
||||
{verifyingId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
|
||||
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
|
||||
placeholder="auditor@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||
Verifizieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Atom, Search, ChevronRight, ChevronLeft, Filter,
|
||||
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
|
||||
Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
|
||||
CATEGORY_OPTIONS,
|
||||
} from '../control-library/components/helpers'
|
||||
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface AtomicStats {
|
||||
total_active: number
|
||||
total_duplicate: number
|
||||
by_domain: Array<{ domain: string; count: number }>
|
||||
by_regulation: Array<{ regulation: string; count: number }>
|
||||
avg_regulation_coverage: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ATOMIC CONTROLS PAGE
|
||||
// =============================================================================
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function AtomicControlsPage() {
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [stats, setStats] = useState<AtomicStats | null>(null)
|
||||
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Mode
|
||||
const [mode, setMode] = useState<'list' | 'detail'>('list')
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [searchQuery])
|
||||
|
||||
// Build query params
|
||||
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||
const p = new URLSearchParams()
|
||||
p.set('control_type', 'atomic')
|
||||
// Exclude duplicates — show only active masters
|
||||
if (!extra?.release_state) {
|
||||
// Don't filter by state for count queries that already have it
|
||||
}
|
||||
if (severityFilter) p.set('severity', severityFilter)
|
||||
if (domainFilter) p.set('domain', domainFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, categoryFilter, debouncedSearch])
|
||||
|
||||
// Load stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
|
||||
if (res.ok) setStats(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Load controls page
|
||||
const loadControls = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
|
||||
const sortOrder = sortBy === 'newest' ? 'desc' : 'asc'
|
||||
const offset = (currentPage - 1) * PAGE_SIZE
|
||||
|
||||
const qs = buildParams({
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(offset),
|
||||
})
|
||||
|
||||
const countQs = buildParams()
|
||||
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||||
])
|
||||
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) {
|
||||
const data = await countRes.json()
|
||||
setTotalCount(data.total || 0)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
// Loading
|
||||
if (loading && controls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null) }}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onReview={() => {}}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSelectedControl(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LIST VIEW
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Atom className="w-6 h-6 text-violet-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
Deduplizierte atomare Controls mit Herkunftsnachweis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadControls(); loadStats() }}
|
||||
className="p-2 text-gray-400 hover:text-violet-600"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-violet-500">Master Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
|
||||
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
|
||||
</div>
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
|
||||
<div className="text-xs text-indigo-500">Regulierungen</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
|
||||
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Atomare Controls durchsuchen (ID, Titel, Objective)..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Domain</option>
|
||||
{stats?.by_domain.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||
>
|
||||
<option value="id">Sortierung: ID</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Aelteste zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
|
||||
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
|
||||
</span>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{controls.map((ctrl) => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{ctrl.source_citation?.source && (
|
||||
<>
|
||||
<span className="text-xs text-blue-600">
|
||||
{ctrl.source_citation.source}
|
||||
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
{ctrl.parent_control_id && (
|
||||
<>
|
||||
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
</>
|
||||
)}
|
||||
<Clock className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{controls.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
Keine atomaren Controls gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||
acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === 'dots' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p as number)}
|
||||
className={`w-8 h-8 text-sm rounded-lg ${
|
||||
currentPage === p
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ConfidenceLevelBadge } from '../evidence/components/anti-fake-badges'
|
||||
|
||||
// Types
|
||||
interface DashboardData {
|
||||
@@ -25,6 +26,15 @@ interface DashboardData {
|
||||
evidence_by_status: Record<string, number>
|
||||
total_risks: number
|
||||
risks_by_level: Record<string, number>
|
||||
multi_score?: {
|
||||
requirement_coverage: number
|
||||
evidence_strength: number
|
||||
validation_quality: number
|
||||
evidence_freshness: number
|
||||
control_effectiveness: number
|
||||
overall_readiness: number
|
||||
hard_blocks: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
interface Regulation {
|
||||
@@ -106,7 +116,46 @@ interface ScoreSnapshot {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type TabKey = 'overview' | 'roadmap' | 'modules' | 'trend'
|
||||
interface TraceabilityAssertion {
|
||||
id: string
|
||||
sentence_text: string
|
||||
assertion_type: string
|
||||
confidence: number
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
interface TraceabilityEvidence {
|
||||
id: string
|
||||
title: string
|
||||
evidence_type: string
|
||||
confidence_level: string
|
||||
status: string
|
||||
assertions: TraceabilityAssertion[]
|
||||
}
|
||||
|
||||
interface TraceabilityCoverage {
|
||||
has_evidence: boolean
|
||||
has_assertions: boolean
|
||||
all_assertions_verified: boolean
|
||||
min_confidence_level: string | null
|
||||
}
|
||||
|
||||
interface TraceabilityControl {
|
||||
id: string
|
||||
control_id: string
|
||||
title: string
|
||||
status: string
|
||||
domain: string
|
||||
evidence: TraceabilityEvidence[]
|
||||
coverage: TraceabilityCoverage
|
||||
}
|
||||
|
||||
interface TraceabilityMatrixData {
|
||||
controls: TraceabilityControl[]
|
||||
summary: Record<string, number>
|
||||
}
|
||||
|
||||
type TabKey = 'overview' | 'roadmap' | 'modules' | 'trend' | 'traceability'
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
gov: 'Governance',
|
||||
@@ -148,6 +197,17 @@ export default function ComplianceHubPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [seeding, setSeeding] = useState(false)
|
||||
const [savingSnapshot, setSavingSnapshot] = useState(false)
|
||||
const [evidenceDistribution, setEvidenceDistribution] = useState<{
|
||||
by_confidence: Record<string, number>
|
||||
four_eyes_pending: number
|
||||
total: number
|
||||
} | null>(null)
|
||||
const [traceabilityMatrix, setTraceabilityMatrix] = useState<TraceabilityMatrixData | null>(null)
|
||||
const [traceabilityLoading, setTraceabilityLoading] = useState(false)
|
||||
const [traceabilityFilter, setTraceabilityFilter] = useState<'all' | 'covered' | 'uncovered' | 'fully_verified'>('all')
|
||||
const [traceabilityDomainFilter, setTraceabilityDomainFilter] = useState<string>('all')
|
||||
const [expandedControls, setExpandedControls] = useState<Set<string>>(new Set())
|
||||
const [expandedEvidence, setExpandedEvidence] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -157,6 +217,7 @@ export default function ComplianceHubPage() {
|
||||
if (activeTab === 'roadmap' && !roadmap) loadRoadmap()
|
||||
if (activeTab === 'modules' && !moduleStatus) loadModuleStatus()
|
||||
if (activeTab === 'trend' && scoreHistory.length === 0) loadScoreHistory()
|
||||
if (activeTab === 'traceability' && !traceabilityMatrix) loadTraceabilityMatrix()
|
||||
}, [activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -182,6 +243,12 @@ export default function ComplianceHubPage() {
|
||||
const data = await actionsRes.json()
|
||||
setNextActions(data.actions || [])
|
||||
}
|
||||
|
||||
// Evidence distribution (Anti-Fake-Evidence Phase 3)
|
||||
try {
|
||||
const evidenceDistRes = await fetch('/api/sdk/v1/compliance/dashboard/evidence-distribution')
|
||||
if (evidenceDistRes.ok) setEvidenceDistribution(await evidenceDistRes.json())
|
||||
} catch { /* silent */ }
|
||||
} catch (err) {
|
||||
console.error('Failed to load compliance data:', err)
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
@@ -214,6 +281,31 @@ export default function ComplianceHubPage() {
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const loadTraceabilityMatrix = async () => {
|
||||
setTraceabilityLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/dashboard/traceability-matrix')
|
||||
if (res.ok) setTraceabilityMatrix(await res.json())
|
||||
} catch { /* silent */ }
|
||||
finally { setTraceabilityLoading(false) }
|
||||
}
|
||||
|
||||
const toggleControlExpanded = (id: string) => {
|
||||
setExpandedControls(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleEvidenceExpanded = (id: string) => {
|
||||
setExpandedEvidence(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const saveSnapshot = async () => {
|
||||
setSavingSnapshot(true)
|
||||
try {
|
||||
@@ -259,6 +351,7 @@ export default function ComplianceHubPage() {
|
||||
{ key: 'roadmap', label: 'Roadmap' },
|
||||
{ key: 'modules', label: 'Module' },
|
||||
{ key: 'trend', label: 'Trend' },
|
||||
{ key: 'traceability', label: 'Traceability' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -411,6 +504,115 @@ export default function ComplianceHubPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Anti-Fake-Evidence Section (Phase 3) */}
|
||||
{dashboard && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Anti-Fake-Evidence Status</h3>
|
||||
|
||||
{/* Confidence Distribution Bar */}
|
||||
{evidenceDistribution && evidenceDistribution.total > 0 && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-slate-500 mb-2">Confidence-Verteilung ({evidenceDistribution.total} Nachweise)</p>
|
||||
<div className="flex h-6 rounded-full overflow-hidden">
|
||||
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
|
||||
const count = evidenceDistribution.by_confidence[level] || 0
|
||||
const pct = (count / evidenceDistribution.total) * 100
|
||||
if (pct === 0) return null
|
||||
const colors: Record<string, string> = {
|
||||
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
|
||||
}
|
||||
return (
|
||||
<div key={level} className={`${colors[level]} flex items-center justify-center text-xs text-white font-medium`}
|
||||
style={{ width: `${pct}%` }} title={`${level}: ${count}`}>
|
||||
{pct >= 10 ? `${level} (${count})` : ''}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
|
||||
const count = evidenceDistribution.by_confidence[level] || 0
|
||||
const dotColors: Record<string, string> = {
|
||||
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
|
||||
}
|
||||
return (
|
||||
<span key={level} className="flex items-center gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColors[level]}`} />
|
||||
{level}: {count}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-Score Dimensions */}
|
||||
{dashboard.multi_score && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-slate-500 mb-2">Multi-dimensionaler Score</p>
|
||||
<div className="space-y-2">
|
||||
{([
|
||||
{ key: 'requirement_coverage', label: 'Anforderungsabdeckung', color: 'bg-blue-500' },
|
||||
{ key: 'evidence_strength', label: 'Evidence-Staerke', color: 'bg-green-500' },
|
||||
{ key: 'validation_quality', label: 'Validierungsqualitaet', color: 'bg-purple-500' },
|
||||
{ key: 'evidence_freshness', label: 'Aktualitaet', color: 'bg-yellow-500' },
|
||||
{ key: 'control_effectiveness', label: 'Control-Wirksamkeit', color: 'bg-indigo-500' },
|
||||
] as const).map(dim => {
|
||||
const value = (dashboard.multi_score as Record<string, number>)[dim.key] || 0
|
||||
return (
|
||||
<div key={dim.key} className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-600 w-44 truncate">{dim.label}</span>
|
||||
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${dim.color} rounded-full transition-all`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 w-12 text-right">{typeof value === 'number' ? value.toFixed(0) : value}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-slate-100">
|
||||
<span className="text-xs font-semibold text-slate-700 w-44">Audit-Readiness</span>
|
||||
<div className="flex-1 h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${
|
||||
(dashboard.multi_score.overall_readiness || 0) >= 80 ? 'bg-green-500' :
|
||||
(dashboard.multi_score.overall_readiness || 0) >= 60 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`} style={{ width: `${dashboard.multi_score.overall_readiness || 0}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700 w-12 text-right">
|
||||
{typeof dashboard.multi_score.overall_readiness === 'number' ? dashboard.multi_score.overall_readiness.toFixed(0) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom row: Four-Eyes + Hard Blocks */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 rounded-lg bg-yellow-50">
|
||||
<div className="text-2xl font-bold text-yellow-700">{evidenceDistribution?.four_eyes_pending || 0}</div>
|
||||
<div className="text-xs text-yellow-600 mt-1">Four-Eyes Reviews ausstehend</div>
|
||||
</div>
|
||||
{dashboard.multi_score?.hard_blocks && dashboard.multi_score.hard_blocks.length > 0 ? (
|
||||
<div className="p-3 rounded-lg bg-red-50">
|
||||
<div className="text-xs font-medium text-red-700 mb-1">Hard Blocks ({dashboard.multi_score.hard_blocks.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{dashboard.multi_score.hard_blocks.slice(0, 3).map((block: string, i: number) => (
|
||||
<li key={i} className="text-xs text-red-600 flex items-start gap-1">
|
||||
<span className="text-red-400 mt-0.5">•</span>
|
||||
<span>{block}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-3 rounded-lg bg-green-50">
|
||||
<div className="text-2xl font-bold text-green-700">0</div>
|
||||
<div className="text-xs text-green-600 mt-1">Keine Hard Blocks</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Actions + Findings */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Next Actions */}
|
||||
@@ -805,6 +1007,232 @@ export default function ComplianceHubPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traceability Tab */}
|
||||
{activeTab === 'traceability' && (
|
||||
<div className="p-6 space-y-6">
|
||||
{traceabilityLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
<span className="ml-3 text-slate-500">Traceability Matrix wird geladen...</span>
|
||||
</div>
|
||||
) : !traceabilityMatrix ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Daten verfuegbar. Stellen Sie sicher, dass Controls und Evidence vorhanden sind.
|
||||
</div>
|
||||
) : (() => {
|
||||
const summary = traceabilityMatrix.summary
|
||||
const totalControls = summary.total_controls || 0
|
||||
const covered = summary.covered || 0
|
||||
const fullyVerified = summary.fully_verified || 0
|
||||
const uncovered = summary.uncovered || 0
|
||||
|
||||
const filteredControls = (traceabilityMatrix.controls || []).filter(ctrl => {
|
||||
if (traceabilityFilter === 'covered' && !ctrl.coverage.has_evidence) return false
|
||||
if (traceabilityFilter === 'uncovered' && ctrl.coverage.has_evidence) return false
|
||||
if (traceabilityFilter === 'fully_verified' && !ctrl.coverage.all_assertions_verified) return false
|
||||
if (traceabilityDomainFilter !== 'all' && ctrl.domain !== traceabilityDomainFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const domains = [...new Set(traceabilityMatrix.controls.map(c => c.domain))].sort()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-purple-700">{totalControls}</div>
|
||||
<div className="text-sm text-purple-600">Total Controls</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-blue-700">{covered}</div>
|
||||
<div className="text-sm text-blue-600">Abgedeckt</div>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-green-700">{fullyVerified}</div>
|
||||
<div className="text-sm text-green-600">Vollst. verifiziert</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-red-700">{uncovered}</div>
|
||||
<div className="text-sm text-red-600">Unabgedeckt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="flex gap-1">
|
||||
{([
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'covered', label: 'Abgedeckt' },
|
||||
{ key: 'uncovered', label: 'Nicht abgedeckt' },
|
||||
{ key: 'fully_verified', label: 'Vollst. verifiziert' },
|
||||
] as const).map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setTraceabilityFilter(f.key)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
traceabilityFilter === f.key
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-4 w-px bg-slate-300" />
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button
|
||||
onClick={() => setTraceabilityDomainFilter('all')}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
traceabilityDomainFilter === 'all'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle Domains
|
||||
</button>
|
||||
{domains.map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setTraceabilityDomainFilter(d)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
traceabilityDomainFilter === d
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{DOMAIN_LABELS[d] || d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls List */}
|
||||
<div className="space-y-2">
|
||||
{filteredControls.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Keine Controls fuer diesen Filter gefunden.
|
||||
</div>
|
||||
) : filteredControls.map(ctrl => {
|
||||
const isExpanded = expandedControls.has(ctrl.id)
|
||||
const coverageIcon = ctrl.coverage.all_assertions_verified
|
||||
? { symbol: '\u2713', color: 'text-green-600 bg-green-50' }
|
||||
: ctrl.coverage.has_evidence
|
||||
? { symbol: '\u25D0', color: 'text-yellow-600 bg-yellow-50' }
|
||||
: { symbol: '\u2717', color: 'text-red-600 bg-red-50' }
|
||||
|
||||
return (
|
||||
<div key={ctrl.id} className="border rounded-lg overflow-hidden">
|
||||
{/* Control Row */}
|
||||
<button
|
||||
onClick={() => toggleControlExpanded(ctrl.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span className="text-slate-400 text-xs">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className={`w-7 h-7 flex items-center justify-center rounded-full text-sm font-medium ${coverageIcon.color}`}>
|
||||
{coverageIcon.symbol}
|
||||
</span>
|
||||
<code className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-600 font-mono">{ctrl.control_id}</code>
|
||||
<span className="text-sm text-slate-800 flex-1 truncate">{ctrl.title}</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded">{DOMAIN_LABELS[ctrl.domain] || ctrl.domain}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700'
|
||||
: ctrl.status === 'in_progress' ? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{ctrl.status}
|
||||
</span>
|
||||
<ConfidenceLevelBadge level={ctrl.coverage.min_confidence_level} />
|
||||
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
|
||||
{ctrl.evidence.length} Ev.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded: Evidence list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-slate-50">
|
||||
{ctrl.evidence.length === 0 ? (
|
||||
<div className="px-8 py-3 text-xs text-slate-400 italic">
|
||||
Kein Evidence verknuepft.
|
||||
</div>
|
||||
) : ctrl.evidence.map(ev => {
|
||||
const evExpanded = expandedEvidence.has(ev.id)
|
||||
return (
|
||||
<div key={ev.id} className="border-b last:border-b-0">
|
||||
<button
|
||||
onClick={() => toggleEvidenceExpanded(ev.id)}
|
||||
className="w-full flex items-center gap-3 px-8 py-2 text-left hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span className="text-slate-400 text-xs">{evExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className="text-sm text-slate-700 flex-1 truncate">{ev.title}</span>
|
||||
<span className="text-xs bg-white border px-2 py-0.5 rounded text-slate-500">{ev.evidence_type}</span>
|
||||
<ConfidenceLevelBadge level={ev.confidence_level} />
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
ev.status === 'valid' ? 'bg-green-100 text-green-700'
|
||||
: ev.status === 'expired' ? 'bg-red-100 text-red-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{ev.status}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
|
||||
{ev.assertions.length} Ass.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded: Assertions list */}
|
||||
{evExpanded && ev.assertions.length > 0 && (
|
||||
<div className="bg-white border-t">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-12 py-1.5 text-left text-slate-500 font-medium">Aussage</th>
|
||||
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-20">Typ</th>
|
||||
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-24">Konfidenz</th>
|
||||
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-16">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.assertions.map(a => (
|
||||
<tr key={a.id} className="hover:bg-slate-50">
|
||||
<td className="px-12 py-1.5 text-slate-700">{a.sentence_text}</td>
|
||||
<td className="px-3 py-1.5 text-center text-slate-500">{a.assertion_type}</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<span className={`font-medium ${
|
||||
a.confidence >= 0.8 ? 'text-green-600'
|
||||
: a.confidence >= 0.5 ? 'text-yellow-600'
|
||||
: 'text-red-600'
|
||||
}`}>
|
||||
{(a.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
{a.verified
|
||||
? <span className="text-green-600 font-medium">{'\u2713'}</span>
|
||||
: <span className="text-slate-400">{'\u2717'}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
ArrowLeft, ExternalLink, BookOpen, Scale, FileText,
|
||||
Eye, CheckCircle2, Trash2, Pencil, Clock,
|
||||
ChevronLeft, SkipForward, GitMerge, Search,
|
||||
ChevronLeft, SkipForward, GitMerge, Search, Landmark,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
|
||||
ExtractionMethodBadge, RegulationCountBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
@@ -24,6 +27,58 @@ interface SimilarControl {
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
parent_control_id: string
|
||||
parent_title: string
|
||||
link_type: string
|
||||
confidence: number
|
||||
source_regulation: string | null
|
||||
source_article: string | null
|
||||
parent_citation: Record<string, string> | null
|
||||
obligation: {
|
||||
text: string
|
||||
action: string
|
||||
object: string
|
||||
normative_strength: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface TraceabilityData {
|
||||
control_id: string
|
||||
title: string
|
||||
is_atomic: boolean
|
||||
parent_links: ParentLink[]
|
||||
children: Array<{
|
||||
control_id: string
|
||||
title: string
|
||||
category: string
|
||||
severity: string
|
||||
decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
// Extended provenance fields
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
}
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface ControlDetailProps {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
@@ -31,6 +86,8 @@ interface ControlDetailProps {
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onRefresh?: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||
// Review mode navigation
|
||||
reviewMode?: boolean
|
||||
reviewIndex?: number
|
||||
@@ -46,6 +103,8 @@ export function ControlDetail({
|
||||
onDelete,
|
||||
onReview,
|
||||
onRefresh,
|
||||
onNavigateToControl,
|
||||
onCompare,
|
||||
reviewMode,
|
||||
reviewIndex = 0,
|
||||
reviewTotal = 0,
|
||||
@@ -56,9 +115,42 @@ export function ControlDetail({
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false)
|
||||
const [selectedDuplicates, setSelectedDuplicates] = useState<Set<string>>(new Set())
|
||||
const [merging, setMerging] = useState(false)
|
||||
const [traceability, setTraceability] = useState<TraceabilityData | null>(null)
|
||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||
const [v1Matches, setV1Matches] = useState<V1Match[]>([])
|
||||
const [loadingV1, setLoadingV1] = useState(false)
|
||||
const eigenentwicklung = isEigenentwicklung(ctrl)
|
||||
|
||||
const loadTraceability = useCallback(async () => {
|
||||
setLoadingTrace(true)
|
||||
try {
|
||||
// Try provenance first (extended data), fall back to traceability
|
||||
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
}
|
||||
if (res.ok) {
|
||||
setTraceability(await res.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoadingTrace(false) }
|
||||
}, [ctrl.control_id])
|
||||
|
||||
const loadV1Matches = useCallback(async () => {
|
||||
if (!eigenentwicklung) { setV1Matches([]); return }
|
||||
setLoadingV1(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
||||
if (res.ok) setV1Matches(await res.json())
|
||||
else setV1Matches([])
|
||||
} catch { setV1Matches([]) }
|
||||
finally { setLoadingV1(false) }
|
||||
}, [ctrl.control_id, eigenentwicklung])
|
||||
|
||||
useEffect(() => {
|
||||
loadSimilarControls()
|
||||
loadTraceability()
|
||||
loadV1Matches()
|
||||
setSelectedDuplicates(new Set())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ctrl.control_id])
|
||||
@@ -124,7 +216,10 @@ export function ControlDetail({
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
|
||||
</div>
|
||||
@@ -164,17 +259,39 @@ export function ControlDetail({
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Gesetzliche Grundlage (Rule 1 + 2) */}
|
||||
{/* Quellennachweis (Rule 1 + 2) — dynamic label based on source_type */}
|
||||
{ctrl.source_citation && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<section className={`border rounded-lg p-4 ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'bg-blue-50 border-blue-200' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'bg-indigo-50 border-indigo-200' :
|
||||
'bg-teal-50 border-teal-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-blue-600" />
|
||||
<h3 className="text-sm font-semibold text-blue-900">Gesetzliche Grundlage</h3>
|
||||
{ctrl.license_rule === 1 && (
|
||||
<Scale className={`w-4 h-4 ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-600' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-600' :
|
||||
'text-teal-600'
|
||||
}`} />
|
||||
<h3 className={`text-sm font-semibold ${
|
||||
ctrl.source_citation.source_type === 'law' ? 'text-blue-900' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-900' :
|
||||
'text-teal-900'
|
||||
}`}>{
|
||||
ctrl.source_citation.source_type === 'law' ? 'Gesetzliche Grundlage' :
|
||||
ctrl.source_citation.source_type === 'guideline' ? 'Behoerdliche Leitlinie' :
|
||||
'Standard / Best Practice'
|
||||
}</h3>
|
||||
{ctrl.source_citation.source_type === 'law' && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Direkte gesetzliche Pflicht</span>
|
||||
)}
|
||||
{ctrl.license_rule === 2 && (
|
||||
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded-full">Standard mit Zitationspflicht</span>
|
||||
{ctrl.source_citation.source_type === 'guideline' && (
|
||||
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">Aufsichtsbehoerdliche Empfehlung</span>
|
||||
)}
|
||||
{(ctrl.source_citation.source_type === 'standard' || (!ctrl.source_citation.source_type && ctrl.license_rule === 2)) && (
|
||||
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded-full">Freiwilliger Standard</span>
|
||||
)}
|
||||
{(!ctrl.source_citation.source_type && ctrl.license_rule === 1) && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">Noch nicht klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -217,6 +334,304 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Regulatorische Abdeckung (Eigenentwicklung) */}
|
||||
{eigenentwicklung && (
|
||||
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-orange-600" />
|
||||
<h3 className="text-sm font-semibold text-orange-900">
|
||||
Regulatorische Abdeckung
|
||||
</h3>
|
||||
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
||||
</div>
|
||||
{v1Matches.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{v1Matches.map((match, i) => (
|
||||
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{match.matched_source && (
|
||||
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">
|
||||
{match.matched_source}
|
||||
</span>
|
||||
)}
|
||||
{match.matched_article && (
|
||||
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">
|
||||
{match.matched_article}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(match.similarity_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(match.matched_control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5"
|
||||
>
|
||||
{match.matched_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">
|
||||
{match.matched_control_id}
|
||||
</span>
|
||||
)}
|
||||
{match.matched_title}
|
||||
</p>
|
||||
</div>
|
||||
{onCompare && (
|
||||
<button
|
||||
onClick={() => onCompare(ctrl, v1Matches)}
|
||||
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
Vergleichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loadingV1 ? (
|
||||
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
||||
{traceability && traceability.parent_links.length > 0 && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Landmark className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary && traceability.regulations_summary.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{traceability.parent_links.map((link, i) => (
|
||||
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{link.source_regulation && (
|
||||
<span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>
|
||||
)}
|
||||
{link.source_article && (
|
||||
<span className="text-sm text-violet-700">{link.source_article}</span>
|
||||
)}
|
||||
{!link.source_regulation && link.parent_citation?.source && (
|
||||
<span className="text-sm font-semibold text-violet-900">
|
||||
{link.parent_citation.source}
|
||||
{link.parent_citation.article && ` — ${link.parent_citation.article}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
|
||||
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{link.link_type === 'decomposition' ? 'Ableitung' :
|
||||
link.link_type === 'dedup_merge' ? 'Dedup' :
|
||||
link.link_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via{' '}
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(link.parent_control_id)}
|
||||
className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{link.parent_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
||||
{link.parent_control_id}
|
||||
</span>
|
||||
)}
|
||||
{link.parent_title && (
|
||||
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
||||
)}
|
||||
</p>
|
||||
{link.obligation && (
|
||||
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
|
||||
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{link.obligation.normative_strength === 'must' ? 'MUSS' :
|
||||
link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{link.obligation.text.slice(0, 200)}
|
||||
{link.obligation.text.length > 200 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Fallback: simple parent display when traceability not loaded yet */}
|
||||
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
|
||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<GitMerge className="w-4 h-4 text-violet-600" />
|
||||
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
</div>
|
||||
<p className="text-sm text-violet-800">
|
||||
Abgeleitet aus Eltern-Control{' '}
|
||||
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
|
||||
{ctrl.parent_control_id || ctrl.parent_control_uuid}
|
||||
</span>
|
||||
{ctrl.parent_control_title && (
|
||||
<span className="text-violet-700 ml-1">— {ctrl.parent_control_title}</span>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References (atomic controls) */}
|
||||
{traceability && traceability.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">
|
||||
Original-Dokumente ({traceability.document_references.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && (
|
||||
<span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations (rich controls) */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' :
|
||||
ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}
|
||||
{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability && traceability.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(dup.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{dup.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{dup.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && (
|
||||
<span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls (rich controls that have atomic children) */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-emerald-900">
|
||||
Abgeleitete Controls ({traceability.children.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(child.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{child.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Impliziter Gesetzesbezug (Rule 3 — reformuliert, kein Originaltext) */}
|
||||
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
@@ -275,7 +690,7 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Evidence */}
|
||||
{/* Evidence — handles both {type, description} objects and plain strings */}
|
||||
{ctrl.evidence.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
|
||||
@@ -283,7 +698,11 @@ export function ControlDetail({
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
{typeof ev === 'string' ? (
|
||||
<div>{ev}</div>
|
||||
) : (
|
||||
<div><span className="font-medium">{ev.type}:</span> {ev.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -337,7 +756,18 @@ export function ControlDetail({
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p>
|
||||
{ctrl.generation_metadata.processing_path && (
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.decomposition_method && (
|
||||
<p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.pass0b_model && (
|
||||
<p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.obligation_type && (
|
||||
<p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>
|
||||
)}
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
// Compact Control Panel (used on both sides of the comparison)
|
||||
// =============================================================================
|
||||
|
||||
function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
||||
export function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
|
||||
{/* Panel Header */}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, SkipForward, Scale,
|
||||
} from 'lucide-react'
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
import { ControlPanel } from './ReviewCompare'
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface V1CompareViewProps {
|
||||
v1Control: CanonicalControl
|
||||
matches: V1Match[]
|
||||
onBack: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function V1CompareView({ v1Control, matches, onBack, onNavigateToControl }: V1CompareViewProps) {
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
||||
const [matchedControl, setMatchedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentMatch = matches[currentMatchIndex]
|
||||
|
||||
// Load the full matched control when index changes
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(currentMatch.matched_control_id)}`)
|
||||
if (res.ok) {
|
||||
setMatchedControl(await res.json())
|
||||
} else {
|
||||
setMatchedControl(null)
|
||||
}
|
||||
} catch {
|
||||
setMatchedControl(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [currentMatch])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-semibold text-gray-900">V1-Vergleich</span>
|
||||
{currentMatch && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
currentMatch.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
currentMatch.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(currentMatch.similarity_score * 100).toFixed(1)}% Aehnlichkeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.max(0, currentMatchIndex - 1))}
|
||||
disabled={currentMatchIndex === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{currentMatchIndex + 1} / {matches.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.min(matches.length - 1, currentMatchIndex + 1))}
|
||||
disabled={currentMatchIndex >= matches.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigate to matched control */}
|
||||
{onNavigateToControl && matchedControl && (
|
||||
<button
|
||||
onClick={() => { onBack(); onNavigateToControl(matchedControl.control_id) }}
|
||||
className="px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50"
|
||||
>
|
||||
Zum Control
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source info bar */}
|
||||
{currentMatch && (currentMatch.matched_source || currentMatch.matched_article) && (
|
||||
<div className="px-6 py-2 bg-blue-50 border-b border-blue-200 flex items-center gap-2 text-sm">
|
||||
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||
{currentMatch.matched_source && (
|
||||
<span className="font-semibold text-blue-900">{currentMatch.matched_source}</span>
|
||||
)}
|
||||
{currentMatch.matched_article && (
|
||||
<span className="text-blue-700">{currentMatch.matched_article}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side-by-Side Panels */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: V1 Eigenentwicklung */}
|
||||
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||
<ControlPanel ctrl={v1Control} label="Eigenentwicklung" highlight />
|
||||
</div>
|
||||
|
||||
{/* Right: Regulatory match */}
|
||||
<div className="w-1/2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
) : matchedControl ? (
|
||||
<ControlPanel ctrl={matchedControl} label="Regulatorisch gedeckt" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Control konnte nicht geladen werden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export interface CanonicalControl {
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: EvidenceItem[]
|
||||
evidence: (EvidenceItem | string)[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
@@ -44,9 +44,15 @@ export interface CanonicalControl {
|
||||
customer_visible?: boolean
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
evidence_type: string | null
|
||||
target_audience: string | string[] | null
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
generation_strategy?: string | null
|
||||
parent_control_uuid?: string | null
|
||||
parent_control_id?: string | null
|
||||
parent_control_title?: string | null
|
||||
decomposition_method?: string | null
|
||||
pipeline_version?: number | string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -98,6 +104,7 @@ export const EMPTY_CONTROL = {
|
||||
tags: [] as string[],
|
||||
verification_method: null as string | null,
|
||||
category: null as string | null,
|
||||
evidence_type: null as string | null,
|
||||
target_audience: null as string | null,
|
||||
}
|
||||
|
||||
@@ -141,6 +148,18 @@ export const CATEGORY_OPTIONS = [
|
||||
{ value: 'identity', label: 'Identitaetsmanagement' },
|
||||
]
|
||||
|
||||
export const EVIDENCE_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
code: { bg: 'bg-sky-100 text-sky-700', label: 'Code' },
|
||||
process: { bg: 'bg-amber-100 text-amber-700', label: 'Prozess' },
|
||||
hybrid: { bg: 'bg-violet-100 text-violet-700', label: 'Hybrid' },
|
||||
}
|
||||
|
||||
export const EVIDENCE_TYPE_OPTIONS = [
|
||||
{ value: 'code', label: 'Code — Technisch (Source Code, IaC, CI/CD)' },
|
||||
{ value: 'process', label: 'Prozess — Organisatorisch (Dokumente, Policies)' },
|
||||
{ value: 'hybrid', label: 'Hybrid — Code + Prozess' },
|
||||
]
|
||||
|
||||
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
||||
// Legacy English keys
|
||||
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
@@ -240,6 +259,13 @@ export function CategoryBadge({ category }: { category: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function EvidenceTypeBadge({ type }: { type: string | null }) {
|
||||
if (!type) return null
|
||||
const config = EVIDENCE_TYPE_CONFIG[type]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
||||
if (!audience) return null
|
||||
|
||||
@@ -268,16 +294,115 @@ export function TargetAudienceBadge({ audience }: { audience: string | string[]
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerationStrategyBadge({ strategy }: { strategy: string | null | undefined }) {
|
||||
export interface CanonicalControlPipelineInfo {
|
||||
pipeline_version?: number | string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
parent_control_uuid?: string | null
|
||||
}
|
||||
|
||||
export function isEigenentwicklung(ctrl: CanonicalControlPipelineInfo & { generation_strategy?: string | null }): boolean {
|
||||
return (
|
||||
(!ctrl.generation_strategy || ctrl.generation_strategy === 'ungrouped') &&
|
||||
(!ctrl.pipeline_version || String(ctrl.pipeline_version) === '1') &&
|
||||
!ctrl.source_citation &&
|
||||
!ctrl.parent_control_uuid
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerationStrategyBadge({ strategy, pipelineInfo }: {
|
||||
strategy: string | null | undefined
|
||||
pipelineInfo?: CanonicalControlPipelineInfo & { generation_strategy?: string | null }
|
||||
}) {
|
||||
// Eigenentwicklung detection: v1 + no source + no parent
|
||||
if (pipelineInfo && isEigenentwicklung(pipelineInfo)) {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-700">Eigenentwicklung</span>
|
||||
}
|
||||
if (!strategy || strategy === 'ungrouped') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">v1</span>
|
||||
}
|
||||
if (strategy === 'document_grouped') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">v2</span>
|
||||
}
|
||||
return null
|
||||
if (strategy === 'phase74_gap_fill') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">v5 Gap</span>
|
||||
}
|
||||
if (strategy === 'pass0b_atomic' || strategy === 'pass0b') {
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">Atomar</span>
|
||||
}
|
||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">{strategy}</span>
|
||||
}
|
||||
|
||||
export const OBLIGATION_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
pflicht: { bg: 'bg-red-100 text-red-700', label: 'Pflicht' },
|
||||
empfehlung: { bg: 'bg-amber-100 text-amber-700', label: 'Empfehlung' },
|
||||
kann: { bg: 'bg-green-100 text-green-700', label: 'Kann' },
|
||||
}
|
||||
|
||||
export function ObligationTypeBadge({ type }: { type: string | null | undefined }) {
|
||||
if (!type) return null
|
||||
const config = OBLIGATION_TYPE_CONFIG[type]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationInfo {
|
||||
candidate_id: string
|
||||
obligation_text: string
|
||||
action: string | null
|
||||
object: string | null
|
||||
normative_strength: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export interface DocumentReference {
|
||||
regulation_code: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
extraction_method: string
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface MergedDuplicate {
|
||||
control_id: string
|
||||
title: string
|
||||
source_regulation: string | null
|
||||
}
|
||||
|
||||
export interface RegulationSummary {
|
||||
regulation_code: string
|
||||
articles: string[]
|
||||
link_types: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE BADGES
|
||||
// =============================================================================
|
||||
|
||||
const EXTRACTION_METHOD_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
exact_match: { bg: 'bg-green-100 text-green-700', label: 'Exakt' },
|
||||
embedding_match: { bg: 'bg-blue-100 text-blue-700', label: 'Embedding' },
|
||||
llm_extracted: { bg: 'bg-violet-100 text-violet-700', label: 'LLM' },
|
||||
inferred: { bg: 'bg-gray-100 text-gray-600', label: 'Abgeleitet' },
|
||||
}
|
||||
|
||||
export function ExtractionMethodBadge({ method }: { method: string }) {
|
||||
const config = EXTRACTION_METHOD_CONFIG[method] || EXTRACTION_METHOD_CONFIG.inferred
|
||||
return <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function RegulationCountBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">
|
||||
{count} {count === 1 ? 'Regulierung' : 'Regulierungen'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||
GenerationStrategyBadge, ObligationTypeBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS, TARGET_AUDIENCE_OPTIONS,
|
||||
} from './components/helpers'
|
||||
import { ControlForm } from './components/ControlForm'
|
||||
import { ControlDetail } from './components/ControlDetail'
|
||||
import { ReviewCompare } from './components/ReviewCompare'
|
||||
import { V1CompareView } from './components/V1CompareView'
|
||||
import { GeneratorModal } from './components/GeneratorModal'
|
||||
|
||||
// =============================================================================
|
||||
@@ -26,6 +27,16 @@ interface ControlsMeta {
|
||||
domains: Array<{ domain: string; count: number }>
|
||||
sources: Array<{ source: string; count: number }>
|
||||
no_source_count: number
|
||||
type_counts?: {
|
||||
rich: number
|
||||
atomic: number
|
||||
eigenentwicklung: number
|
||||
}
|
||||
severity_counts?: Record<string, number>
|
||||
verification_method_counts?: Record<string, number>
|
||||
category_counts?: Record<string, number>
|
||||
evidence_type_counts?: Record<string, number>
|
||||
release_state_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -51,8 +62,11 @@ export default function ControlLibraryPage() {
|
||||
const [stateFilter, setStateFilter] = useState<string>('')
|
||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
const [hideDuplicates, setHideDuplicates] = useState(true)
|
||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
||||
|
||||
// CRUD state
|
||||
@@ -76,6 +90,21 @@ export default function ControlLibraryPage() {
|
||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||
|
||||
// V1 Compare mode
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
|
||||
const [compareMatches, setCompareMatches] = useState<Array<{
|
||||
matched_control_id: string; matched_title: string; matched_objective: string
|
||||
matched_severity: string; matched_category: string
|
||||
matched_source: string | null; matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number; match_rank: number; match_method: string
|
||||
}>>([])
|
||||
|
||||
// Abort controllers for cancelling stale requests
|
||||
const metaAbortRef = useRef<AbortController | null>(null)
|
||||
const controlsAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -92,27 +121,43 @@ export default function ControlLibraryPage() {
|
||||
if (stateFilter) p.set('release_state', stateFilter)
|
||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||
if (categoryFilter) p.set('category', categoryFilter)
|
||||
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||
if (sourceFilter) p.set('source', sourceFilter)
|
||||
if (typeFilter) p.set('control_type', typeFilter)
|
||||
if (hideDuplicates) p.set('exclude_duplicates', 'true')
|
||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||
return p.toString()
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, debouncedSearch])
|
||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||
|
||||
// Load metadata (domains, sources — once + on refresh)
|
||||
const loadMeta = useCallback(async () => {
|
||||
// Load frameworks (once)
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const [fwRes, metaRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=frameworks`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-meta`),
|
||||
])
|
||||
if (fwRes.ok) setFrameworks(await fwRes.json())
|
||||
if (metaRes.ok) setMeta(await metaRes.json())
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Load controls page
|
||||
// Load faceted metadata (reloads when filters change, cancels stale requests)
|
||||
const loadMeta = useCallback(async () => {
|
||||
if (metaAbortRef.current) metaAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
metaAbortRef.current = controller
|
||||
try {
|
||||
const qs = buildParams()
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||
}
|
||||
}, [buildParams])
|
||||
|
||||
// Load controls page (cancels stale requests)
|
||||
const loadControls = useCallback(async () => {
|
||||
if (controlsAbortRef.current) controlsAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
controlsAbortRef.current = controller
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
@@ -131,19 +176,22 @@ export default function ControlLibraryPage() {
|
||||
const countQs = buildParams()
|
||||
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
])
|
||||
|
||||
if (!controller.signal.aborted) {
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
if (countRes.ok) {
|
||||
const data = await countRes.json()
|
||||
setTotalCount(data.total || 0)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [buildParams, sortBy, currentPage])
|
||||
|
||||
@@ -158,22 +206,25 @@ export default function ControlLibraryPage() {
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { loadMeta(); loadReviewCount() }, [loadMeta, loadReviewCount])
|
||||
// Initial load (frameworks only once)
|
||||
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||
|
||||
// Load faceted meta when filters change
|
||||
useEffect(() => { loadMeta() }, [loadMeta])
|
||||
|
||||
// Load controls when filters/page/sort change
|
||||
useEffect(() => { loadControls() }, [loadControls])
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, debouncedSearch, sortBy])
|
||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
|
||||
// Full reload (after CRUD)
|
||||
const fullReload = useCallback(async () => {
|
||||
await Promise.all([loadControls(), loadMeta(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadReviewCount])
|
||||
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
||||
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
||||
|
||||
// CRUD handlers
|
||||
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
|
||||
@@ -392,6 +443,27 @@ export default function ControlLibraryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// V1 COMPARE MODE
|
||||
if (compareMode && compareV1Control) {
|
||||
return (
|
||||
<V1CompareView
|
||||
v1Control={compareV1Control}
|
||||
matches={compareMatches}
|
||||
onBack={() => { setCompareMode(false) }}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) {
|
||||
setCompareMode(false)
|
||||
setSelectedControl(await res.json())
|
||||
setMode('detail')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
const isDuplicateReview = reviewMode && reviewTab === 'duplicates'
|
||||
@@ -461,6 +533,21 @@ export default function ControlLibraryPage() {
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onRefresh={fullReload}
|
||||
onCompare={(ctrl, matches) => {
|
||||
setCompareV1Control(ctrl)
|
||||
setCompareMatches(matches)
|
||||
setCompareMode(true)
|
||||
}}
|
||||
onNavigateToControl={async (controlId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSelectedControl(data)
|
||||
setMode('detail')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
reviewMode={reviewMode}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
@@ -568,7 +655,7 @@ export default function ControlLibraryPage() {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadControls(); loadMeta(); loadReviewCount() }}
|
||||
onClick={() => { loadControls(); loadMeta(); loadFrameworks(); loadReviewCount() }}
|
||||
className="p-2 text-gray-400 hover:text-purple-600"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
@@ -583,10 +670,10 @@ export default function ControlLibraryPage() {
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Schweregrad</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
|
||||
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
|
||||
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
|
||||
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
|
||||
</select>
|
||||
<select
|
||||
value={domainFilter}
|
||||
@@ -604,13 +691,22 @@ export default function ControlLibraryPage() {
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
|
||||
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
|
||||
<option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
|
||||
<option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
|
||||
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
|
||||
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDuplicates}
|
||||
onChange={e => setHideDuplicates(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
Duplikate ausblenden
|
||||
</label>
|
||||
<select
|
||||
value={verificationFilter}
|
||||
onChange={e => setVerificationFilter(e.target.value)}
|
||||
@@ -618,8 +714,9 @@ export default function ControlLibraryPage() {
|
||||
>
|
||||
<option value="">Nachweis</option>
|
||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
||||
))}
|
||||
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
@@ -628,8 +725,20 @@ export default function ControlLibraryPage() {
|
||||
>
|
||||
<option value="">Kategorie</option>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
<option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>
|
||||
))}
|
||||
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select
|
||||
value={evidenceTypeFilter}
|
||||
onChange={e => setEvidenceTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Nachweisart</option>
|
||||
{EVIDENCE_TYPE_OPTIONS.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>
|
||||
))}
|
||||
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
|
||||
</select>
|
||||
<select
|
||||
value={audienceFilter}
|
||||
@@ -664,6 +773,16 @@ export default function ControlLibraryPage() {
|
||||
<option key={s.source} value={s.source}>{s.source} ({s.count})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="rich">Rich Controls{meta?.type_counts ? ` (${meta.type_counts.rich})` : ''}</option>
|
||||
<option value="atomic">Atomare Controls{meta?.type_counts ? ` (${meta.type_counts.atomic})` : ''}</option>
|
||||
<option value="eigenentwicklung">Eigenentwicklung{meta?.type_counts ? ` (${meta.type_counts.eigenentwicklung})` : ''}</option>
|
||||
</select>
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
@@ -760,8 +879,10 @@ export default function ControlLibraryPage() {
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||
<CategoryBadge category={ctrl.category} />
|
||||
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
|
||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{ctrl.risk_score !== null && (
|
||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||
)}
|
||||
|
||||
@@ -196,7 +196,15 @@ function ControlCard({
|
||||
{/* Linked Evidence */}
|
||||
{control.linkedEvidence.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 mb-1 block">Nachweise:</span>
|
||||
<span className="text-xs text-gray-500 mb-1 block">
|
||||
Nachweise: {control.linkedEvidence.length}
|
||||
{(() => {
|
||||
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
||||
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
||||
).length
|
||||
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
||||
})()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedEvidence.map(ev => (
|
||||
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
||||
@@ -205,6 +213,9 @@ function ControlCard({
|
||||
'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{ev.title}
|
||||
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
||||
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -359,6 +370,49 @@ interface RAGControlSuggestion {
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
function TransitionErrorBanner({
|
||||
controlId,
|
||||
violations,
|
||||
onDismiss,
|
||||
}: {
|
||||
controlId: string
|
||||
violations: string[]
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">
|
||||
Status-Transition blockiert ({controlId})
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{violations.map((v, i) => (
|
||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
||||
<span className="text-orange-400 mt-0.5">•</span>
|
||||
<span>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Evidence hinzufuegen →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ControlsPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
@@ -373,6 +427,9 @@ export default function ControlsPage() {
|
||||
const [showRagPanel, setShowRagPanel] = useState(false)
|
||||
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
|
||||
|
||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
// Track linked evidence per control
|
||||
@@ -385,7 +442,7 @@ export default function ControlsPage() {
|
||||
const data = await res.json()
|
||||
const allEvidence = data.evidence || data
|
||||
if (Array.isArray(allEvidence)) {
|
||||
const map: Record<string, { id: string; title: string; status: string }[]> = {}
|
||||
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
|
||||
for (const ev of allEvidence) {
|
||||
const ctrlId = ev.control_id || ''
|
||||
if (!map[ctrlId]) map[ctrlId] = []
|
||||
@@ -393,6 +450,7 @@ export default function ControlsPage() {
|
||||
id: ev.id,
|
||||
title: ev.title || ev.name || 'Nachweis',
|
||||
status: ev.status || 'pending',
|
||||
confidenceLevel: ev.confidence_level || undefined,
|
||||
})
|
||||
}
|
||||
setEvidenceMap(map)
|
||||
@@ -483,20 +541,56 @@ export default function ControlsPage() {
|
||||
: 0
|
||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||
|
||||
const handleStatusChange = async (controlId: string, status: ImplementationStatus) => {
|
||||
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
||||
// Remember old status for rollback
|
||||
const oldControl = state.controls.find(c => c.id === controlId)
|
||||
const oldStatus = oldControl?.implementationStatus
|
||||
|
||||
// Optimistic update
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: status } },
|
||||
payload: { id: controlId, data: { implementationStatus: newStatus } },
|
||||
})
|
||||
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ implementation_status: status }),
|
||||
body: JSON.stringify({ implementation_status: newStatus }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// Rollback optimistic update
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
|
||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||
|
||||
if (res.status === 409 && err.detail?.violations) {
|
||||
setTransitionError({ controlId, violations: err.detail.violations })
|
||||
} else {
|
||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||
setError(msg)
|
||||
}
|
||||
} else {
|
||||
// Clear any previous transition error for this control
|
||||
if (transitionError?.controlId === controlId) {
|
||||
setTransitionError(null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — SDK state is already updated
|
||||
// Network error — rollback
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
setError('Netzwerkfehler bei Status-Aenderung')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +839,15 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
||||
{transitionError && (
|
||||
<TransitionErrorBanner
|
||||
controlId={transitionError.controlId}
|
||||
violations={transitionError.violations}
|
||||
onDismiss={() => setTransitionError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
|
||||
@@ -45,13 +45,15 @@ const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
// Sicherheitskonzepte (Migration 051)
|
||||
{ key: 'security', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept'] },
|
||||
// Policy-Bibliothek (Migration 054)
|
||||
{ key: 'security', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'cybersecurity_policy'] },
|
||||
// Policy-Bibliothek (Migration 071/072)
|
||||
{ key: 'it_security_policies', label: 'IT-Sicherheit Policies', types: ['information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy', 'logging_policy', 'backup_policy', 'incident_response_policy', 'change_management_policy', 'patch_management_policy', 'asset_management_policy', 'cloud_security_policy', 'devsecops_policy', 'secrets_management_policy', 'vulnerability_management_policy'] },
|
||||
{ key: 'data_policies', label: 'Daten-Policies', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
|
||||
{ key: 'hr_policies', label: 'Personal-Policies', types: ['employee_security_policy', 'security_awareness_policy', 'acceptable_use', 'remote_work_policy', 'offboarding_policy'] },
|
||||
{ key: 'vendor_policies', label: 'Lieferanten-Policies', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy'] },
|
||||
{ key: 'bcm_policies', label: 'BCM/Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy'] },
|
||||
// Modul-Dokumente (Migration 073)
|
||||
{ key: 'module_docs', label: 'DSGVO-Dokumente', types: ['vvt_register', 'tom_documentation', 'loeschkonzept', 'pflichtenregister'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
const badgeBase = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confidence Level Badge (E0–E4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const confidenceColors: Record<string, string> = {
|
||||
E0: "bg-red-100 text-red-800",
|
||||
E1: "bg-yellow-100 text-yellow-800",
|
||||
E2: "bg-blue-100 text-blue-800",
|
||||
E3: "bg-green-100 text-green-800",
|
||||
E4: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const confidenceLabels: Record<string, string> = {
|
||||
E0: "E0 — Generiert",
|
||||
E1: "E1 — Manuell",
|
||||
E2: "E2 — Intern validiert",
|
||||
E3: "E3 — System-beobachtet",
|
||||
E4: "E4 — Extern auditiert",
|
||||
}
|
||||
|
||||
export function ConfidenceLevelBadge({ level }: { level?: string | null }) {
|
||||
if (!level) return null
|
||||
const color = confidenceColors[level] || "bg-gray-100 text-gray-800"
|
||||
const label = confidenceLabels[level] || level
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Truth Status Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const truthColors: Record<string, string> = {
|
||||
generated: "bg-violet-100 text-violet-800",
|
||||
uploaded: "bg-gray-100 text-gray-800",
|
||||
observed: "bg-blue-100 text-blue-800",
|
||||
validated: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
audited: "bg-emerald-100 text-emerald-800",
|
||||
}
|
||||
|
||||
const truthLabels: Record<string, string> = {
|
||||
generated: "Generiert",
|
||||
uploaded: "Hochgeladen",
|
||||
observed: "Beobachtet",
|
||||
validated: "Validiert",
|
||||
rejected: "Abgelehnt",
|
||||
audited: "Auditiert",
|
||||
}
|
||||
|
||||
export function TruthStatusBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return null
|
||||
const color = truthColors[status] || "bg-gray-100 text-gray-800"
|
||||
const label = truthLabels[status] || status
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generation Mode Badge (sparkles icon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function GenerationModeBadge({ mode }: { mode?: string | null }) {
|
||||
if (!mode) return null
|
||||
return (
|
||||
<span className={`${badgeBase} bg-violet-100 text-violet-800`}>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0v-1H3a1 1 0 010-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 7.512a1 1 0 010 1.976l-3.354.313-1.18 4.456a1 1 0 01-1.932 0l-1.18-4.456-3.354-.313a1 1 0 010-1.976l3.354-.313 1.18-4.456A1 1 0 0112 2z" />
|
||||
</svg>
|
||||
KI-generiert
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval Status Badge (Four-Eyes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approvalColors: Record<string, string> = {
|
||||
none: "bg-gray-100 text-gray-600",
|
||||
pending_first: "bg-yellow-100 text-yellow-800",
|
||||
first_approved: "bg-blue-100 text-blue-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
}
|
||||
|
||||
const approvalLabels: Record<string, string> = {
|
||||
none: "Kein Review",
|
||||
pending_first: "Warte auf 1. Review",
|
||||
first_approved: "1. Review OK",
|
||||
approved: "Genehmigt (4-Augen)",
|
||||
rejected: "Abgelehnt",
|
||||
}
|
||||
|
||||
export function ApprovalStatusBadge({
|
||||
status,
|
||||
requiresFourEyes,
|
||||
}: {
|
||||
status?: string | null
|
||||
requiresFourEyes?: boolean | null
|
||||
}) {
|
||||
if (!requiresFourEyes) return null
|
||||
const s = status || "none"
|
||||
const color = approvalColors[s] || "bg-gray-100 text-gray-600"
|
||||
const label = approvalLabels[s] || s
|
||||
return <span className={`${badgeBase} ${color}`}>{label}</span>
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
ConfidenceLevelBadge,
|
||||
TruthStatusBadge,
|
||||
GenerationModeBadge,
|
||||
ApprovalStatusBadge,
|
||||
} from './components/anti-fake-badges'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -28,6 +34,12 @@ interface DisplayEvidence {
|
||||
status: DisplayStatus
|
||||
fileSize: string
|
||||
fileUrl: string | null
|
||||
// Anti-Fake-Evidence Phase 2
|
||||
confidenceLevel: string | null
|
||||
truthStatus: string | null
|
||||
generationMode: string | null
|
||||
approvalStatus: string | null
|
||||
requiresFourEyes: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -162,7 +174,327 @@ const evidenceTemplates: EvidenceTemplate[] = [
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
|
||||
// =============================================================================
|
||||
// CONFIDENCE FILTER COLORS (matching anti-fake-badges)
|
||||
// =============================================================================
|
||||
|
||||
const confidenceFilterColors: Record<string, string> = {
|
||||
E0: 'bg-red-200 text-red-800',
|
||||
E1: 'bg-yellow-200 text-yellow-800',
|
||||
E2: 'bg-blue-200 text-blue-800',
|
||||
E3: 'bg-green-200 text-green-800',
|
||||
E4: 'bg-emerald-200 text-emerald-800',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REVIEW MODAL
|
||||
// =============================================================================
|
||||
|
||||
function ReviewModal({ evidence, onClose, onSuccess }: { evidence: DisplayEvidence; onClose: () => void; onSuccess: () => void }) {
|
||||
const [confidenceLevel, setConfidenceLevel] = useState(evidence.confidenceLevel || 'E1')
|
||||
const [truthStatus, setTruthStatus] = useState(evidence.truthStatus || 'uploaded')
|
||||
const [reviewedBy, setReviewedBy] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/review`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confidence_level: confidenceLevel, truth_status: truthStatus, reviewed_by: reviewedBy }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Review fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confidenceLevels = [
|
||||
{ value: 'E0', label: 'E0 — Generiert' },
|
||||
{ value: 'E1', label: 'E1 — Manuell' },
|
||||
{ value: 'E2', label: 'E2 — Intern validiert' },
|
||||
{ value: 'E3', label: 'E3 — System-beobachtet' },
|
||||
{ value: 'E4', label: 'E4 — Extern auditiert' },
|
||||
]
|
||||
|
||||
const truthStatuses = [
|
||||
{ value: 'generated', label: 'Generiert' },
|
||||
{ value: 'uploaded', label: 'Hochgeladen' },
|
||||
{ value: 'observed', label: 'Beobachtet' },
|
||||
{ value: 'validated', label: 'Validiert' },
|
||||
{ value: 'audited', label: 'Auditiert' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Reviewen</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
|
||||
|
||||
{/* Current values */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Aktuelles Confidence-Level:</span>
|
||||
<span className="font-medium">{evidence.confidenceLevel || '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Aktueller Truth-Status:</span>
|
||||
<span className="font-medium">{evidence.truthStatus || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New confidence level */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Confidence-Level</label>
|
||||
<select value={confidenceLevel} onChange={e => setConfidenceLevel(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{confidenceLevels.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* New truth status */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Truth-Status</label>
|
||||
<select value={truthStatus} onChange={e => setTruthStatus(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{truthStatuses.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reviewed by */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
|
||||
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
|
||||
placeholder="reviewer@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Four-eyes warning */}
|
||||
{evidence.requiresFourEyes && evidence.approvalStatus !== 'approved' && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="text-sm text-yellow-800">
|
||||
<p className="font-medium">4-Augen-Prinzip aktiv</p>
|
||||
<p>Dieser Nachweis erfordert eine zusaetzliche Freigabe durch einen zweiten Reviewer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
|
||||
{submitting ? 'Wird gespeichert...' : 'Review abschliessen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REJECT MODAL
|
||||
// =============================================================================
|
||||
|
||||
function RejectModal({ evidence, onClose, onSuccess }: { evidence: DisplayEvidence; onClose: () => void; onSuccess: () => void }) {
|
||||
const [reviewedBy, setReviewedBy] = useState('')
|
||||
const [rejectionReason, setRejectionReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
|
||||
if (!rejectionReason.trim()) { setError('Bitte Ablehnungsgrund angeben'); return }
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/reject`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewed_by: reviewedBy, rejection_reason: rejectionReason }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Ablehnung fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Ablehnen</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
|
||||
|
||||
{/* Reviewed by */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
|
||||
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
|
||||
placeholder="reviewer@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Rejection reason */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ablehnungsgrund</label>
|
||||
<textarea value={rejectionReason} onChange={e => setRejectionReason(e.target.value)}
|
||||
placeholder="Bitte beschreiben Sie den Grund fuer die Ablehnung..."
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none" />
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50">
|
||||
{submitting ? 'Wird abgelehnt...' : 'Ablehnen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUDIT TRAIL PANEL
|
||||
// =============================================================================
|
||||
|
||||
function AuditTrailPanel({ evidenceId, onClose }: { evidenceId: string; onClose: () => void }) {
|
||||
const [entries, setEntries] = useState<{ id: string; action: string; actor: string; timestamp: string; details: Record<string, unknown> | null }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/compliance/audit-trail?entity_type=evidence&entity_id=${evidenceId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const mapped = (data.entries || []).map((e: Record<string, unknown>) => ({
|
||||
id: e.id as string,
|
||||
action: e.action as string,
|
||||
actor: (e.performed_by || 'System') as string,
|
||||
timestamp: (e.performed_at || '') as string,
|
||||
details: {
|
||||
...(e.field_changed ? { field: e.field_changed } : {}),
|
||||
...(e.old_value ? { old: e.old_value } : {}),
|
||||
...(e.new_value ? { new: e.new_value } : {}),
|
||||
...(e.change_summary ? { summary: e.change_summary } : {}),
|
||||
} as Record<string, unknown>,
|
||||
}))
|
||||
setEntries(mapped)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [evidenceId])
|
||||
|
||||
const actionLabels: Record<string, { label: string; color: string }> = {
|
||||
created: { label: 'Erstellt', color: 'bg-blue-100 text-blue-700' },
|
||||
uploaded: { label: 'Hochgeladen', color: 'bg-purple-100 text-purple-700' },
|
||||
reviewed: { label: 'Reviewed', color: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
|
||||
updated: { label: 'Aktualisiert', color: 'bg-yellow-100 text-yellow-700' },
|
||||
deleted: { label: 'Geloescht', color: 'bg-gray-100 text-gray-700' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-emerald-100 text-emerald-700' },
|
||||
four_eyes_first: { label: '1. Review (4-Augen)', color: 'bg-blue-100 text-blue-700' },
|
||||
four_eyes_final: { label: 'Finale Freigabe (4-Augen)', color: 'bg-emerald-100 text-emerald-700' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl mx-4 p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Audit-Trail</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<p>Keine Audit-Trail-Eintraege vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, idx) => {
|
||||
const meta = actionLabels[entry.action] || { label: entry.action, color: 'bg-gray-100 text-gray-700' }
|
||||
return (
|
||||
<div key={entry.id || idx} className="relative flex items-start gap-4 pl-10">
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-2.5 top-1.5 w-3 h-3 rounded-full bg-white border-2 border-purple-400" />
|
||||
|
||||
<div className="flex-1 bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${meta.color}`}>{meta.label}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">{entry.actor || 'System'}</span>
|
||||
</div>
|
||||
{entry.details && Object.keys(entry.details).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 font-mono bg-white rounded p-2 border">
|
||||
{Object.entries(entry.details).map(([k, v]) => (
|
||||
<div key={k}><span className="text-gray-400">{k}:</span> {String(v)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EVIDENCE CARD
|
||||
// =============================================================================
|
||||
|
||||
function EvidenceCard({ evidence, onDelete, onView, onDownload, onReview, onReject, onShowHistory }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void; onReview: () => void; onReject: () => void; onShowHistory: () => void }) {
|
||||
const typeIcons = {
|
||||
document: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -221,9 +553,15 @@ function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: Di
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
|
||||
{statusLabels[evidence.status]}
|
||||
</span>
|
||||
<ConfidenceLevelBadge level={evidence.confidenceLevel} />
|
||||
<TruthStatusBadge status={evidence.truthStatus} />
|
||||
<GenerationModeBadge mode={evidence.generationMode} />
|
||||
<ApprovalStatusBadge status={evidence.approvalStatus} requiresFourEyes={evidence.requiresFourEyes} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
|
||||
|
||||
@@ -275,6 +613,31 @@ function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: Di
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
{/* Review button — visible when review is possible */}
|
||||
{(evidence.approvalStatus === 'none' || evidence.approvalStatus === 'pending_first' || evidence.approvalStatus === 'first_approved' || !evidence.approvalStatus) && evidence.approvalStatus !== 'approved' && evidence.approvalStatus !== 'rejected' && (
|
||||
<button
|
||||
onClick={onReview}
|
||||
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Reviewen
|
||||
</button>
|
||||
)}
|
||||
{/* Reject button — visible for four-eyes evidence that's not yet resolved */}
|
||||
{evidence.requiresFourEyes && evidence.approvalStatus !== 'rejected' && evidence.approvalStatus !== 'approved' && (
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="px-3 py-1 text-sm text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
{/* History button */}
|
||||
<button
|
||||
onClick={onShowHistory}
|
||||
className="px-3 py-1 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Historie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,6 +745,15 @@ export default function EvidencePage() {
|
||||
const [pageSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
// Anti-Fake-Evidence metadata (keyed by evidence ID)
|
||||
const [antiFakeMeta, setAntiFakeMeta] = useState<Record<string, {
|
||||
confidenceLevel: string | null
|
||||
truthStatus: string | null
|
||||
generationMode: string | null
|
||||
approvalStatus: string | null
|
||||
requiresFourEyes: boolean
|
||||
}>>({})
|
||||
|
||||
// Evidence Checks state
|
||||
const [checks, setChecks] = useState<EvidenceCheck[]>([])
|
||||
const [checksLoading, setChecksLoading] = useState(false)
|
||||
@@ -393,6 +765,13 @@ export default function EvidencePage() {
|
||||
const [coverageReport, setCoverageReport] = useState<CoverageReport | null>(null)
|
||||
const [seedingChecks, setSeedingChecks] = useState(false)
|
||||
|
||||
// Phase 3: Review/Reject/AuditTrail state
|
||||
const [reviewEvidence, setReviewEvidence] = useState<DisplayEvidence | null>(null)
|
||||
const [rejectEvidence, setRejectEvidence] = useState<DisplayEvidence | null>(null)
|
||||
const [auditTrailId, setAuditTrailId] = useState<string | null>(null)
|
||||
const [confidenceFilter, setConfidenceFilter] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
// Fetch evidence from backend on mount and when page changes
|
||||
useEffect(() => {
|
||||
const fetchEvidence = async () => {
|
||||
@@ -404,8 +783,18 @@ export default function EvidencePage() {
|
||||
if (data.total !== undefined) setTotal(data.total)
|
||||
const backendEvidence = data.evidence || data
|
||||
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
|
||||
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
|
||||
id: (e.id || '') as string,
|
||||
const metaMap: typeof antiFakeMeta = {}
|
||||
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => {
|
||||
const id = (e.id || '') as string
|
||||
metaMap[id] = {
|
||||
confidenceLevel: (e.confidence_level || null) as string | null,
|
||||
truthStatus: (e.truth_status || null) as string | null,
|
||||
generationMode: (e.generation_mode || null) as string | null,
|
||||
approvalStatus: (e.approval_status || null) as string | null,
|
||||
requiresFourEyes: !!e.requires_four_eyes,
|
||||
}
|
||||
return {
|
||||
id,
|
||||
controlId: (e.control_id || '') as string,
|
||||
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
|
||||
name: (e.title || e.name || '') as string,
|
||||
@@ -415,7 +804,9 @@ export default function EvidencePage() {
|
||||
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
|
||||
uploadedBy: (e.uploaded_by || 'System') as string,
|
||||
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
|
||||
}))
|
||||
}
|
||||
})
|
||||
setAntiFakeMeta(metaMap)
|
||||
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
|
||||
setError(null)
|
||||
return
|
||||
@@ -463,12 +854,13 @@ export default function EvidencePage() {
|
||||
}
|
||||
|
||||
fetchEvidence()
|
||||
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize, refreshKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK evidence to display evidence
|
||||
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
||||
const template = evidenceTemplates.find(t => t.id === ev.id)
|
||||
|
||||
const meta = antiFakeMeta[ev.id]
|
||||
return {
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
@@ -485,12 +877,18 @@ export default function EvidencePage() {
|
||||
status: getEvidenceStatus(ev.validUntil),
|
||||
fileSize: template?.fileSize || 'Unbekannt',
|
||||
fileUrl: ev.fileUrl,
|
||||
confidenceLevel: meta?.confidenceLevel || null,
|
||||
truthStatus: meta?.truthStatus || null,
|
||||
generationMode: meta?.generationMode || null,
|
||||
approvalStatus: meta?.approvalStatus || null,
|
||||
requiresFourEyes: meta?.requiresFourEyes || false,
|
||||
}
|
||||
})
|
||||
|
||||
const filteredEvidence = filter === 'all'
|
||||
const filteredEvidence = (filter === 'all'
|
||||
? displayEvidence
|
||||
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
|
||||
).filter(e => !confidenceFilter || e.confidenceLevel === confidenceFilter)
|
||||
|
||||
const validCount = displayEvidence.filter(e => e.status === 'valid').length
|
||||
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
|
||||
@@ -803,6 +1201,20 @@ export default function EvidencePage() {
|
||||
f === 'certificate' ? 'Zertifikate' : 'Audit-Berichte'}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-gray-300 mx-1">|</span>
|
||||
{['E0', 'E1', 'E2', 'E3', 'E4'].map(level => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => setConfidenceFilter(confidenceFilter === level ? null : level)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
confidenceFilter === level
|
||||
? confidenceFilterColors[level]
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
@@ -818,6 +1230,9 @@ export default function EvidencePage() {
|
||||
onDelete={() => handleDelete(ev.id)}
|
||||
onView={() => handleView(ev)}
|
||||
onDownload={() => handleDownload(ev)}
|
||||
onReview={() => setReviewEvidence(ev)}
|
||||
onReject={() => setRejectEvidence(ev)}
|
||||
onShowHistory={() => setAuditTrailId(ev.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1106,6 +1521,28 @@ export default function EvidencePage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase 3 Modals */}
|
||||
{reviewEvidence && (
|
||||
<ReviewModal
|
||||
evidence={reviewEvidence}
|
||||
onClose={() => setReviewEvidence(null)}
|
||||
onSuccess={() => { setReviewEvidence(null); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
{rejectEvidence && (
|
||||
<RejectModal
|
||||
evidence={rejectEvidence}
|
||||
onClose={() => setRejectEvidence(null)}
|
||||
onSuccess={() => { setRejectEvidence(null); setRefreshKey(k => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
{auditTrailId && (
|
||||
<AuditTrailPanel
|
||||
evidenceId={auditTrailId}
|
||||
onClose={() => setAuditTrailId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
LoeschfristPolicy, LegalHold, StorageLocation,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
formatRetentionDuration, isPolicyOverdue, getActiveLegalHolds,
|
||||
getEffectiveDeletionTrigger,
|
||||
} from '@/lib/sdk/loeschfristen-types'
|
||||
import { BASELINE_TEMPLATES, templateToPolicy, getTemplateById, getAllTemplateTags } from '@/lib/sdk/loeschfristen-baseline-catalog'
|
||||
import {
|
||||
PROFILING_STEPS, ProfilingAnswer, ProfilingStep,
|
||||
isStepComplete, getProfilingProgress, generatePoliciesFromProfile,
|
||||
@@ -27,12 +25,18 @@ import {
|
||||
exportPoliciesAsJSON, exportPoliciesAsCSV,
|
||||
generateComplianceSummary, downloadFile,
|
||||
} from '@/lib/sdk/loeschfristen-export'
|
||||
import {
|
||||
buildLoeschkonzeptHtml,
|
||||
type LoeschkonzeptOrgHeader,
|
||||
type LoeschkonzeptRevision,
|
||||
createDefaultLoeschkonzeptOrgHeader,
|
||||
} from '@/lib/sdk/loeschfristen-document'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export'
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export' | 'loeschkonzept'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: TagInput
|
||||
@@ -101,7 +105,6 @@ function TagInput({
|
||||
|
||||
export default function LoeschfristenPage() {
|
||||
const router = useRouter()
|
||||
const sdk = useSDK()
|
||||
|
||||
// ---- Core state ----
|
||||
const [tab, setTab] = useState<Tab>('uebersicht')
|
||||
@@ -121,15 +124,19 @@ export default function LoeschfristenPage() {
|
||||
// ---- Compliance state ----
|
||||
const [complianceResult, setComplianceResult] = useState<ComplianceCheckResult | null>(null)
|
||||
|
||||
// ---- Legal Hold management ----
|
||||
const [managingLegalHolds, setManagingLegalHolds] = useState(false)
|
||||
|
||||
// ---- Saving state ----
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// ---- VVT data ----
|
||||
const [vvtActivities, setVvtActivities] = useState<any[]>([])
|
||||
|
||||
// ---- Vendor data ----
|
||||
const [vendorList, setVendorList] = useState<Array<{id: string, name: string}>>([])
|
||||
|
||||
// ---- Loeschkonzept document state ----
|
||||
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
|
||||
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Persistence (API-backed)
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -184,6 +191,7 @@ export default function LoeschfristenPage() {
|
||||
responsiblePerson: raw.responsible_person || '',
|
||||
releaseProcess: raw.release_process || '',
|
||||
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
|
||||
linkedVendorIds: raw.linked_vendor_ids || [],
|
||||
status: raw.status || 'DRAFT',
|
||||
lastReviewDate: raw.last_review_date || base.lastReviewDate,
|
||||
nextReviewDate: raw.next_review_date || base.nextReviewDate,
|
||||
@@ -218,6 +226,7 @@ export default function LoeschfristenPage() {
|
||||
responsible_person: p.responsiblePerson,
|
||||
release_process: p.releaseProcess,
|
||||
linked_vvt_activity_ids: p.linkedVVTActivityIds,
|
||||
linked_vendor_ids: p.linkedVendorIds,
|
||||
status: p.status,
|
||||
last_review_date: p.lastReviewDate || null,
|
||||
next_review_date: p.nextReviewDate || null,
|
||||
@@ -247,6 +256,59 @@ export default function LoeschfristenPage() {
|
||||
})
|
||||
}, [tab, editingId])
|
||||
|
||||
// Load vendor list from API
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
const items = data?.data?.items || []
|
||||
setVendorList(items.map((v: any) => ({ id: v.id, name: v.name })))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load Loeschkonzept org header from VVT organization data + revisions from localStorage
|
||||
useEffect(() => {
|
||||
// Load revisions from localStorage
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_loeschkonzept_revisions')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) setRevisions(parsed)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Load org header from localStorage (user overrides)
|
||||
try {
|
||||
const raw = localStorage.getItem('bp_loeschkonzept_orgheader')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setOrgHeader(prev => ({ ...prev, ...parsed }))
|
||||
return // User has saved org header, skip VVT fetch
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Fallback: fetch from VVT organization API
|
||||
fetch('/api/sdk/v1/compliance/vvt/organization')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setOrgHeader(prev => ({
|
||||
...prev,
|
||||
organizationName: data.organization_name || data.organizationName || prev.organizationName,
|
||||
industry: data.industry || prev.industry,
|
||||
dpoName: data.dpo_name || data.dpoName || prev.dpoName,
|
||||
dpoContact: data.dpo_contact || data.dpoContact || prev.dpoContact,
|
||||
responsiblePerson: data.responsible_person || data.responsiblePerson || prev.responsiblePerson,
|
||||
employeeCount: data.employee_count || data.employeeCount || prev.employeeCount,
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch(() => { /* ignore */ })
|
||||
}, [])
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Derived
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -489,6 +551,7 @@ export default function LoeschfristenPage() {
|
||||
{ key: 'editor', label: 'Editor' },
|
||||
{ key: 'generator', label: 'Generator' },
|
||||
{ key: 'export', label: 'Export & Compliance' },
|
||||
{ key: 'loeschkonzept', label: 'Loeschkonzept' },
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -1355,13 +1418,13 @@ export default function LoeschfristenPage() {
|
||||
Verarbeitungstaetigkeit aus Ihrem VVT.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
|
||||
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Verknuepfte Taetigkeiten:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVvtIds.map((vvtId: string) => {
|
||||
{policy.linkedVVTActivityIds.map((vvtId: string) => {
|
||||
const activity = vvtActivities.find(
|
||||
(a: any) => a.id === vvtId,
|
||||
)
|
||||
@@ -1376,8 +1439,8 @@ export default function LoeschfristenPage() {
|
||||
onClick={() =>
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVvtIds: (
|
||||
p.linkedVvtIds || []
|
||||
linkedVVTActivityIds: (
|
||||
p.linkedVVTActivityIds || []
|
||||
).filter((id: string) => id !== vvtId),
|
||||
}))
|
||||
}
|
||||
@@ -1396,11 +1459,11 @@ export default function LoeschfristenPage() {
|
||||
const val = e.target.value
|
||||
if (
|
||||
val &&
|
||||
!(policy.linkedVvtIds || []).includes(val)
|
||||
!(policy.linkedVVTActivityIds || []).includes(val)
|
||||
) {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVvtIds: [...(p.linkedVvtIds || []), val],
|
||||
linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val],
|
||||
}))
|
||||
}
|
||||
e.target.value = ''
|
||||
@@ -1413,7 +1476,7 @@ export default function LoeschfristenPage() {
|
||||
{vvtActivities
|
||||
.filter(
|
||||
(a: any) =>
|
||||
!(policy.linkedVvtIds || []).includes(a.id),
|
||||
!(policy.linkedVVTActivityIds || []).includes(a.id),
|
||||
)
|
||||
.map((a: any) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
@@ -1432,6 +1495,95 @@ export default function LoeschfristenPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sektion 5b: Auftragsverarbeiter-Verknuepfung */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
5b. Verknuepfte Auftragsverarbeiter
|
||||
</h3>
|
||||
|
||||
{vendorList.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Verknuepfen Sie diese Loeschfrist mit relevanten Auftragsverarbeitern.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVendorIds && policy.linkedVendorIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Verknuepfte Auftragsverarbeiter:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVendorIds.map((vendorId: string) => {
|
||||
const vendor = vendorList.find(
|
||||
(v) => v.id === vendorId,
|
||||
)
|
||||
return (
|
||||
<span
|
||||
key={vendorId}
|
||||
className="inline-flex items-center gap-1 bg-orange-100 text-orange-800 text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{vendor?.name || vendorId}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVendorIds: (
|
||||
p.linkedVendorIds || []
|
||||
).filter((id: string) => id !== vendorId),
|
||||
}))
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-900"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (
|
||||
val &&
|
||||
!(policy.linkedVendorIds || []).includes(val)
|
||||
) {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVendorIds: [...(p.linkedVendorIds || []), val],
|
||||
}))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="">
|
||||
Auftragsverarbeiter verknuepfen...
|
||||
</option>
|
||||
{vendorList
|
||||
.filter(
|
||||
(v) =>
|
||||
!(policy.linkedVendorIds || []).includes(v.id),
|
||||
)
|
||||
.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name || v.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">
|
||||
Keine Auftragsverarbeiter gefunden. Erstellen Sie zuerst
|
||||
Auftragsverarbeiter im Vendor-Compliance-Modul, um hier Verknuepfungen
|
||||
herstellen zu koennen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sektion 6: Review-Einstellungen */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
@@ -2278,6 +2430,316 @@ export default function LoeschfristenPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Tab 5: Loeschkonzept Document
|
||||
// ==========================================================================
|
||||
|
||||
function handleOrgHeaderChange(field: keyof LoeschkonzeptOrgHeader, value: string | string[]) {
|
||||
const updated = { ...orgHeader, [field]: value }
|
||||
setOrgHeader(updated)
|
||||
localStorage.setItem('bp_loeschkonzept_orgheader', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
function handleAddRevision() {
|
||||
const newRev: LoeschkonzeptRevision = {
|
||||
version: orgHeader.loeschkonzeptVersion,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
author: orgHeader.dpoName || orgHeader.responsiblePerson || '',
|
||||
changes: '',
|
||||
}
|
||||
const updated = [...revisions, newRev]
|
||||
setRevisions(updated)
|
||||
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
function handleUpdateRevision(index: number, field: keyof LoeschkonzeptRevision, value: string) {
|
||||
const updated = revisions.map((r, i) => i === index ? { ...r, [field]: value } : r)
|
||||
setRevisions(updated)
|
||||
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
function handleRemoveRevision(index: number) {
|
||||
const updated = revisions.filter((_, i) => i !== index)
|
||||
setRevisions(updated)
|
||||
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
function handlePrintLoeschkonzept() {
|
||||
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(htmlContent)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadLoeschkonzeptHtml() {
|
||||
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
|
||||
downloadFile(htmlContent, `loeschkonzept-${new Date().toISOString().split('T')[0]}.html`, 'text/html;charset=utf-8')
|
||||
}
|
||||
|
||||
function renderLoeschkonzept() {
|
||||
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Action bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Loeschkonzept (Art. 5/17/30 DSGVO)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Druckfertiges Loeschkonzept mit Deckblatt, Loeschregeln, VVT-Verknuepfung und Compliance-Status.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownloadLoeschkonzeptHtml}
|
||||
disabled={activePolicies.length === 0}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintLoeschkonzept}
|
||||
disabled={activePolicies.length === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
|
||||
Als PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activePolicies.length === 0 && (
|
||||
<div className="bg-yellow-50 text-yellow-700 text-sm rounded-lg p-3 border border-yellow-200">
|
||||
Keine aktiven Policies vorhanden. Erstellen Sie mindestens eine Policy, um das Loeschkonzept zu generieren.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-4">Organisationsdaten (Deckblatt)</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Organisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.organizationName}
|
||||
onChange={e => handleOrgHeaderChange('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="Name der Organisation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Branche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.industry}
|
||||
onChange={e => handleOrgHeaderChange('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. IT / Software"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenschutzbeauftragter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoName}
|
||||
onChange={e => handleOrgHeaderChange('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="Name des DSB"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">DSB-Kontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoContact}
|
||||
onChange={e => handleOrgHeaderChange('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="E-Mail oder Telefon"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Verantwortlicher (Art. 4 Nr. 7)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.responsiblePerson}
|
||||
onChange={e => handleOrgHeaderChange('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="Name des Verantwortlichen"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Mitarbeiter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.employeeCount}
|
||||
onChange={e => handleOrgHeaderChange('employeeCount', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. 50-249"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.loeschkonzeptVersion}
|
||||
onChange={e => handleOrgHeaderChange('loeschkonzeptVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Pruefintervall</label>
|
||||
<select
|
||||
value={orgHeader.reviewInterval}
|
||||
onChange={e => handleOrgHeaderChange('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="Vierteljaehrlich">Vierteljaehrlich</option>
|
||||
<option value="Halbjaehrlich">Halbjaehrlich</option>
|
||||
<option value="Jaehrlich">Jaehrlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Letzte Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.lastReviewDate}
|
||||
onChange={e => handleOrgHeaderChange('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Naechste Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.nextReviewDate}
|
||||
onChange={e => handleOrgHeaderChange('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revisions */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button
|
||||
onClick={handleAddRevision}
|
||||
className="text-xs bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-lg px-3 py-1.5 font-medium transition"
|
||||
>
|
||||
+ Revision hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
Noch keine Revisionen. Die Erstversion wird automatisch im Dokument eingefuegt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{revisions.map((rev, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[80px_120px_1fr_1fr_32px] gap-2 items-start">
|
||||
<input
|
||||
type="text"
|
||||
value={rev.version}
|
||||
onChange={e => handleUpdateRevision(idx, 'version', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
placeholder="1.1"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={rev.date}
|
||||
onChange={e => handleUpdateRevision(idx, 'date', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rev.author}
|
||||
onChange={e => handleUpdateRevision(idx, 'author', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
placeholder="Autor"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rev.changes}
|
||||
onChange={e => handleUpdateRevision(idx, 'changes', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
placeholder="Beschreibung der Aenderungen"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveRevision(idx)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Revision entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
|
||||
{/* Cover preview */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-2xl font-bold text-purple-700 mb-1">Loeschkonzept</div>
|
||||
<div className="text-sm text-purple-500 mb-4">gemaess Art. 5/17/30 DSGVO</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{orgHeader.organizationName || <span className="text-gray-400 italic">Organisation nicht angegeben</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
Version {orgHeader.loeschkonzeptVersion} | {new Date().toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section list */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">12 Sektionen</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
|
||||
<div>1. Ziel und Zweck</div>
|
||||
<div>7. Auftragsverarbeiter</div>
|
||||
<div>2. Geltungsbereich</div>
|
||||
<div>8. Legal Hold Verfahren</div>
|
||||
<div>3. Grundprinzipien</div>
|
||||
<div>9. Verantwortlichkeiten</div>
|
||||
<div>4. Loeschregeln-Uebersicht</div>
|
||||
<div>10. Pruef-/Revisionszyklus</div>
|
||||
<div>5. Detaillierte Loeschregeln</div>
|
||||
<div>11. Compliance-Status</div>
|
||||
<div>6. VVT-Verknuepfung</div>
|
||||
<div>12. Aenderungshistorie</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
|
||||
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
|
||||
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
|
||||
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVendorIds.length > 0).length}</strong> Vendor-Verknuepfungen</span>
|
||||
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
|
||||
{complianceResult && (
|
||||
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Main render
|
||||
// ==========================================================================
|
||||
@@ -2317,6 +2779,7 @@ export default function LoeschfristenPage() {
|
||||
{tab === 'editor' && renderEditor()}
|
||||
{tab === 'generator' && renderGenerator()}
|
||||
{tab === 'export' && renderExport()}
|
||||
{tab === 'loeschkonzept' && renderLoeschkonzept()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel'
|
||||
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
|
||||
import { ObligationDocumentTab } from '@/components/sdk/obligations/ObligationDocumentTab'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
|
||||
import type { ApplicableRegulation } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
|
||||
import { runObligationComplianceCheck } from '@/lib/sdk/obligations-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// Types (local only — Obligation imported from obligations-compliance.ts)
|
||||
// =============================================================================
|
||||
|
||||
interface Obligation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
source_article: string
|
||||
deadline: string | null
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
responsible: string
|
||||
linked_systems: string[]
|
||||
assessment_id?: string
|
||||
rule_code?: string
|
||||
notes?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface ObligationStats {
|
||||
pending: number
|
||||
in_progress: number
|
||||
@@ -50,6 +35,7 @@ interface ObligationFormData {
|
||||
priority: string
|
||||
responsible: string
|
||||
linked_systems: string
|
||||
linked_vendor_ids: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
@@ -63,11 +49,26 @@ const EMPTY_FORM: ObligationFormData = {
|
||||
priority: 'medium',
|
||||
responsible: '',
|
||||
linked_systems: '',
|
||||
linked_vendor_ids: '',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
const API = '/api/sdk/v1/compliance/obligations'
|
||||
|
||||
// =============================================================================
|
||||
// Tab definitions
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'profiling' | 'gap-analyse' | 'pflichtenregister'
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'uebersicht', label: 'Uebersicht' },
|
||||
{ key: 'editor', label: 'Detail-Editor' },
|
||||
{ key: 'profiling', label: 'Profiling' },
|
||||
{ key: 'gap-analyse', label: 'Gap-Analyse' },
|
||||
{ key: 'pflichtenregister', label: 'Pflichtenregister' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// Status helpers
|
||||
// =============================================================================
|
||||
@@ -262,6 +263,18 @@ function ObligationModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte Auftragsverarbeiter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.linked_vendor_ids}
|
||||
onChange={e => update('linked_vendor_ids', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Kommagetrennt: Vendor-ID-1, Vendor-ID-2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">IDs der Auftragsverarbeiter aus dem Vendor Register</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
@@ -365,6 +378,19 @@ function ObligationDetail({ obligation, onClose, onStatusChange, onEdit, onDelet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obligation.linked_vendor_ids && obligation.linked_vendor_ids.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500">Verknuepfte Auftragsverarbeiter</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{obligation.linked_vendor_ids.map(id => (
|
||||
<a key={id} href="/sdk/vendor-compliance" className="px-2 py-0.5 text-xs bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100 transition-colors">
|
||||
{id}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{obligation.notes && (
|
||||
<div>
|
||||
<span className="text-gray-500">Notizen</span>
|
||||
@@ -559,9 +585,15 @@ export default function ObligationsPage() {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editObligation, setEditObligation] = useState<Obligation | null>(null)
|
||||
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
|
||||
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
|
||||
const [profiling, setProfiling] = useState(false)
|
||||
const [applicableRegs, setApplicableRegs] = useState<ApplicableRegulation[]>([])
|
||||
const [activeTab, setActiveTab] = useState<Tab>('uebersicht')
|
||||
|
||||
// Compliance check result — auto-computed when obligations change
|
||||
const complianceResult = useMemo<ObligationComplianceCheckResult | null>(() => {
|
||||
if (obligations.length === 0) return null
|
||||
return runObligationComplianceCheck(obligations)
|
||||
}, [obligations])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -613,6 +645,7 @@ export default function ObligationsPage() {
|
||||
priority: form.priority,
|
||||
responsible: form.responsible || null,
|
||||
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
notes: form.notes || null,
|
||||
}),
|
||||
})
|
||||
@@ -634,12 +667,12 @@ export default function ObligationsPage() {
|
||||
priority: form.priority,
|
||||
responsible: form.responsible || null,
|
||||
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
notes: form.notes || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen')
|
||||
await loadData()
|
||||
// Refresh detail if open
|
||||
if (detailObligation?.id === id) {
|
||||
const updated = await fetch(`${API}/${id}`)
|
||||
if (updated.ok) setDetailObligation(await updated.json())
|
||||
@@ -656,7 +689,6 @@ export default function ObligationsPage() {
|
||||
const updated = await res.json()
|
||||
setObligations(prev => prev.map(o => o.id === id ? updated : o))
|
||||
if (detailObligation?.id === id) setDetailObligation(updated)
|
||||
// Refresh stats
|
||||
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -672,7 +704,6 @@ export default function ObligationsPage() {
|
||||
setProfiling(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Build payload from real CompanyProfile + Scope data
|
||||
const profile = sdkState.companyProfile
|
||||
const scopeState = sdkState.complianceScope
|
||||
const scopeAnswers = scopeState?.answers || []
|
||||
@@ -682,7 +713,6 @@ export default function ObligationsPage() {
|
||||
if (profile) {
|
||||
payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown>
|
||||
} else {
|
||||
// Fallback: Minimaldaten wenn kein Profil vorhanden
|
||||
payload = {
|
||||
employee_count: 50,
|
||||
industry: 'technology',
|
||||
@@ -702,11 +732,9 @@ export default function ObligationsPage() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
// Store applicable regulations for the info box
|
||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||
setApplicableRegs(regs)
|
||||
|
||||
// Extract obligations from response (can be nested under overview)
|
||||
const rawObls = data.overview?.obligations || data.obligations || []
|
||||
if (rawObls.length > 0) {
|
||||
const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({
|
||||
@@ -738,11 +766,6 @@ export default function ObligationsPage() {
|
||||
const stepInfo = STEP_EXPLANATIONS['obligations']
|
||||
|
||||
const filteredObligations = obligations.filter(o => {
|
||||
// Status/priority filter
|
||||
if (filter === 'ai') {
|
||||
if (!o.source.toLowerCase().includes('ai')) return false
|
||||
}
|
||||
// Regulation filter
|
||||
if (regulationFilter !== 'all') {
|
||||
const src = o.source?.toLowerCase() || ''
|
||||
const key = regulationFilter.toLowerCase()
|
||||
@@ -751,91 +774,12 @@ export default function ObligationsPage() {
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{(showModal || editObligation) && !detailObligation && (
|
||||
<ObligationModal
|
||||
initial={editObligation ? {
|
||||
title: editObligation.title,
|
||||
description: editObligation.description,
|
||||
source: editObligation.source,
|
||||
source_article: editObligation.source_article,
|
||||
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
|
||||
status: editObligation.status,
|
||||
priority: editObligation.priority,
|
||||
responsible: editObligation.responsible,
|
||||
linked_systems: editObligation.linked_systems?.join(', ') || '',
|
||||
notes: editObligation.notes || '',
|
||||
} : undefined}
|
||||
onClose={() => { setShowModal(false); setEditObligation(null) }}
|
||||
onSave={async (form) => {
|
||||
if (editObligation) {
|
||||
await handleUpdate(editObligation.id, form)
|
||||
setEditObligation(null)
|
||||
} else {
|
||||
await handleCreate(form)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailObligation && (
|
||||
<ObligationDetail
|
||||
obligation={detailObligation}
|
||||
onClose={() => setDetailObligation(null)}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => {
|
||||
setEditObligation(detailObligation)
|
||||
setDetailObligation(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<StepHeader
|
||||
stepId="obligations"
|
||||
title={stepInfo?.title || 'Pflichten-Management'}
|
||||
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
|
||||
explanation={stepInfo?.explanation || ''}
|
||||
tips={stepInfo?.tips || []}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleAutoProfiling}
|
||||
disabled={profiling}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{profiling ? 'Profiling...' : 'Auto-Profiling'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGapAnalysis(v => !v)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm ${
|
||||
showGapAnalysis ? 'bg-purple-100 text-purple-700' : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Gap-Analyse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab Content Renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderUebersichtTab = () => (
|
||||
<>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
|
||||
@@ -872,12 +816,13 @@ export default function ObligationsPage() {
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ label: 'Ausstehend', value: stats?.pending ?? 0, color: 'text-gray-600', border: 'border-gray-200' },
|
||||
{ label: 'In Bearbeitung',value: stats?.in_progress ?? 0, color: 'text-blue-600', border: 'border-blue-200' },
|
||||
{ label: 'Ueberfaellig', value: stats?.overdue ?? 0, color: 'text-red-600', border: 'border-red-200' },
|
||||
{ label: 'Abgeschlossen', value: stats?.completed ?? 0, color: 'text-green-600', border: 'border-green-200'},
|
||||
{ label: 'Compliance-Score', value: complianceResult ? complianceResult.score : '—', color: 'text-purple-600', border: 'border-purple-200'},
|
||||
].map(s => (
|
||||
<div key={s.label} className={`bg-white rounded-xl border ${s.border} p-5`}>
|
||||
<div className={`text-xs ${s.color}`}>{s.label}</div>
|
||||
@@ -901,9 +846,26 @@ export default function ObligationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis View */}
|
||||
{showGapAnalysis && (
|
||||
<GapAnalysisView />
|
||||
{/* Compliance Issues Summary */}
|
||||
{complianceResult && complianceResult.issues.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Compliance-Befunde ({complianceResult.issues.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{complianceResult.issues.map((issue, i) => (
|
||||
<div key={i} className="flex items-start gap-3 text-sm">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
|
||||
issue.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
|
||||
issue.severity === 'HIGH' ? 'bg-orange-100 text-orange-700' :
|
||||
issue.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{issue.severity === 'CRITICAL' ? 'Kritisch' : issue.severity === 'HIGH' ? 'Hoch' : issue.severity === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="text-gray-700">{issue.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regulation Filter Chips */}
|
||||
@@ -970,7 +932,7 @@ export default function ObligationsPage() {
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Keine Pflichten gefunden</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
|
||||
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
@@ -982,6 +944,220 @@ export default function ObligationsPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderEditorTab = () => (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Pflichten bearbeiten ({obligations.length})</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
|
||||
>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-gray-500 text-sm">Lade...</p>}
|
||||
{!loading && obligations.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">Noch keine Pflichten vorhanden. Erstellen Sie eine neue Pflicht oder nutzen Sie Auto-Profiling.</p>
|
||||
)}
|
||||
{!loading && obligations.length > 0 && (
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||
{obligations.map(o => (
|
||||
<div
|
||||
key={o.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditObligation(o)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${STATUS_COLORS[o.status]}`}>
|
||||
{STATUS_LABELS[o.status]}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${PRIORITY_COLORS[o.priority]}`}>
|
||||
{PRIORITY_LABELS[o.priority]}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 truncate">{o.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-gray-400">{o.source}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setEditObligation(o) }}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderProfilingTab = () => (
|
||||
<>
|
||||
{error && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
|
||||
)}
|
||||
|
||||
{!sdkState.companyProfile && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-700">
|
||||
Kein Unternehmensprofil vorhanden. Auto-Profiling verwendet Beispieldaten.{' '}
|
||||
<a href="/sdk/company-profile" className="underline font-medium">Profil anlegen →</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-3">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Auto-Profiling</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 mb-4">
|
||||
Ermittelt automatisch anwendbare Regulierungen und Pflichten aus dem Unternehmensprofil und Compliance-Scope.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAutoProfiling}
|
||||
disabled={profiling}
|
||||
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{profiling ? 'Profiling laeuft...' : 'Auto-Profiling starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applicableRegs.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">Anwendbare Regulierungen</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{applicableRegs.map(reg => (
|
||||
<span
|
||||
key={reg.id}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white border border-blue-300 text-blue-800"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{reg.name}
|
||||
{reg.classification && <span className="text-blue-500">({reg.classification})</span>}
|
||||
<span className="text-blue-400">{reg.obligation_count} Pflichten</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderGapAnalyseTab = () => (
|
||||
<GapAnalysisView />
|
||||
)
|
||||
|
||||
const renderPflichtenregisterTab = () => (
|
||||
<ObligationDocumentTab
|
||||
obligations={obligations}
|
||||
complianceResult={complianceResult}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'uebersicht': return renderUebersichtTab()
|
||||
case 'editor': return renderEditorTab()
|
||||
case 'profiling': return renderProfilingTab()
|
||||
case 'gap-analyse': return renderGapAnalyseTab()
|
||||
case 'pflichtenregister': return renderPflichtenregisterTab()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{(showModal || editObligation) && !detailObligation && (
|
||||
<ObligationModal
|
||||
initial={editObligation ? {
|
||||
title: editObligation.title,
|
||||
description: editObligation.description,
|
||||
source: editObligation.source,
|
||||
source_article: editObligation.source_article,
|
||||
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
|
||||
status: editObligation.status,
|
||||
priority: editObligation.priority,
|
||||
responsible: editObligation.responsible,
|
||||
linked_systems: editObligation.linked_systems?.join(', ') || '',
|
||||
notes: editObligation.notes || '',
|
||||
} : undefined}
|
||||
onClose={() => { setShowModal(false); setEditObligation(null) }}
|
||||
onSave={async (form) => {
|
||||
if (editObligation) {
|
||||
await handleUpdate(editObligation.id, form)
|
||||
setEditObligation(null)
|
||||
} else {
|
||||
await handleCreate(form)
|
||||
setShowModal(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailObligation && (
|
||||
<ObligationDetail
|
||||
obligation={detailObligation}
|
||||
onClose={() => setDetailObligation(null)}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDelete={handleDelete}
|
||||
onEdit={() => {
|
||||
setEditObligation(detailObligation)
|
||||
setDetailObligation(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<StepHeader
|
||||
stepId="obligations"
|
||||
title={stepInfo?.title || 'Pflichten-Management'}
|
||||
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
|
||||
explanation={stepInfo?.explanation || ''}
|
||||
tips={stepInfo?.tips || []}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Pflicht hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-b-2 border-purple-500 text-purple-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:border-b-2 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
checkpointId: 'CP-UC',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl.',
|
||||
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad — assistiv/teilautomatisiert/vollautomatisiert. (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA).',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl. Inkl. BetrVG-Mitbestimmungspruefung und Betriebsrats-Konflikt-Score.',
|
||||
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad + BetrVG — assistiv/teilautomatisiert/vollautomatisiert, plus 3 BetrVG-Toggles: Ueberwachungseignung, HR-Entscheidungsunterstuetzung, BR-Konsultation. Das SDK berechnet daraus einen Betriebsrats-Konflikt-Score (0-100) und leitet BetrVG-Pflichten ab (§87, §90, §94, §95). (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA). Die Collection bp_compliance_datenschutz enthaelt 14 BAG-Urteile zu IT-Mitbestimmung (M365, SAP, SaaS, Video).',
|
||||
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
||||
inputs: ['companyProfile'],
|
||||
outputs: ['useCases'],
|
||||
@@ -155,6 +155,27 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
isOptional: false,
|
||||
url: '/sdk/use-cases',
|
||||
},
|
||||
{
|
||||
id: 'ai-registration',
|
||||
name: 'EU AI Database Registrierung',
|
||||
nameShort: 'EU-Reg',
|
||||
package: 'vorbereitung',
|
||||
seq: 350,
|
||||
checkpointId: 'CP-REG',
|
||||
checkpointType: 'CONDITIONAL',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Registrierung von Hochrisiko-KI-Systemen in der EU AI Database gemaess Art. 49 KI-Verordnung.',
|
||||
descriptionLong: 'Fuer Hochrisiko-KI-Systeme (Annex III) ist eine Registrierung in der EU AI Database Pflicht. Ein 6-Schritte-Wizard fuehrt durch den Prozess: (1) Anbieter-Daten — Name, Rechtsform, Adresse, EU-Repraesentant. (2) System-Details — Name, Version, Beschreibung, Einsatzzweck (vorausgefuellt aus UCCA Assessment). (3) Klassifikation — Risikoklasse und Annex III Kategorie (aus Decision Tree). (4) Konformitaet — CE-Kennzeichnung, Notified Body. (5) Trainingsdaten — Zusammenfassung der Datenquellen (Art. 10). (6) Pruefung + Export — JSON-Download fuer EU-Datenbank-Submission. Der Status-Workflow ist: Entwurf → Bereit → Eingereicht → Registriert.',
|
||||
legalBasis: 'Art. 49 KI-Verordnung (EU) 2024/1689',
|
||||
inputs: ['useCases', 'companyProfile'],
|
||||
outputs: ['euRegistration'],
|
||||
prerequisiteSteps: ['use-case-assessment'],
|
||||
dbTables: ['ai_system_registrations'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: true,
|
||||
url: '/sdk/ai-registration',
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
name: 'Dokument-Import',
|
||||
@@ -672,19 +693,19 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
id: 'vendor-compliance',
|
||||
name: 'Vendor Compliance',
|
||||
nameShort: 'Vendor',
|
||||
package: 'betrieb',
|
||||
seq: 4200,
|
||||
package: 'dokumentation',
|
||||
seq: 2500,
|
||||
checkpointId: 'CP-VEND',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Pruefung und Verwaltung aller Auftragsverarbeiter und Drittanbieter.',
|
||||
descriptionLong: 'Vendor Compliance verwaltet alle externen Dienstleister, die im Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter nach Art. 28 DSGVO). Fuer jeden Vendor wird geprueft: Gibt es einen AVV? Wo werden Daten gespeichert (EU/Drittland)? Welche TOMs hat der Vendor? Gibt es Subunternehmer? Die Pruefung umfasst auch regelmässige Re-Assessments und die Verwaltung von Standardvertragsklauseln (SCCs) fuer Drittlandtransfers.',
|
||||
description: 'Pruefung und Verwaltung aller Auftragsverarbeiter und Drittanbieter — Cross-Modul-Integration mit VVT, Obligations, TOM und Loeschfristen.',
|
||||
descriptionLong: 'Vendor Compliance verwaltet alle externen Dienstleister, die im Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter nach Art. 28 DSGVO). Fuer jeden Vendor wird geprueft: Gibt es einen AVV? Wo werden Daten gespeichert (EU/Drittland)? Welche TOMs hat der Vendor? Gibt es Subunternehmer? Cross-Modul-Integration: VVT-Processor-Tab liest Vendors mit role=PROCESSOR direkt aus der Vendor-API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids (JSONB), TOM zeigt Vendor-Controls als Querverweis.',
|
||||
legalBasis: 'Art. 28 DSGVO (Auftragsverarbeiter), Art. 44-49 (Drittlandtransfer)',
|
||||
inputs: ['modules', 'vvt'],
|
||||
outputs: ['vendorAssessments'],
|
||||
prerequisiteSteps: ['escalations'],
|
||||
dbTables: [],
|
||||
dbMode: 'none',
|
||||
outputs: ['vendorAssessments', 'vendorControlInstances'],
|
||||
prerequisiteSteps: ['vvt'],
|
||||
dbTables: ['vendor_vendors', 'vendor_contracts', 'vendor_findings', 'vendor_control_instances', 'compliance_templates'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: ['bp_compliance_recht'],
|
||||
ragPurpose: 'AVV-Vorlagen und Pruefkataloge',
|
||||
isOptional: false,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { useTOMGenerator } from '@/lib/sdk/tom-generator/context'
|
||||
import { DerivedTOM } from '@/lib/sdk/tom-generator/types'
|
||||
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab } from '@/components/sdk/tom-dashboard'
|
||||
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab, TOMDocumentTab } from '@/components/sdk/tom-dashboard'
|
||||
import { runTOMComplianceCheck, type TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export'
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export' | 'tom-dokument'
|
||||
|
||||
interface TabDefinition {
|
||||
key: Tab
|
||||
@@ -24,6 +25,7 @@ const TABS: TabDefinition[] = [
|
||||
{ key: 'editor', label: 'Detail-Editor' },
|
||||
{ key: 'generator', label: 'Generator' },
|
||||
{ key: 'gap-export', label: 'Gap-Analyse & Export' },
|
||||
{ key: 'tom-dokument', label: 'TOM-Dokument' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
@@ -33,7 +35,7 @@ const TABS: TabDefinition[] = [
|
||||
export default function TOMPage() {
|
||||
const router = useRouter()
|
||||
const sdk = useSDK()
|
||||
const { state, dispatch, bulkUpdateTOMs, runGapAnalysis } = useTOMGenerator()
|
||||
const { state, dispatch, runGapAnalysis } = useTOMGenerator()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -41,6 +43,58 @@ export default function TOMPage() {
|
||||
|
||||
const [tab, setTab] = useState<Tab>('uebersicht')
|
||||
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
|
||||
const [complianceResult, setComplianceResult] = useState<TOMComplianceCheckResult | null>(null)
|
||||
const [vendorControls, setVendorControls] = useState<Array<{
|
||||
vendorId: string
|
||||
vendorName: string
|
||||
controlId: string
|
||||
controlName: string
|
||||
domain: string
|
||||
status: string
|
||||
lastTestedAt?: string
|
||||
}>>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance check (auto-run when derivedTOMs change)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.derivedTOMs && Array.isArray(state.derivedTOMs) && state.derivedTOMs.length > 0) {
|
||||
setComplianceResult(runTOMComplianceCheck(state))
|
||||
}
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendor controls cross-reference (fetch when overview tab is active)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'uebersicht') return
|
||||
Promise.all([
|
||||
fetch('/api/sdk/v1/vendor-compliance/control-instances?limit=500').then(r => r.ok ? r.json() : null),
|
||||
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500').then(r => r.ok ? r.json() : null),
|
||||
]).then(([ciData, vendorData]) => {
|
||||
const instances = ciData?.data?.items || []
|
||||
const vendors = vendorData?.data?.items || []
|
||||
const vendorMap = new Map<string, string>()
|
||||
for (const v of vendors) {
|
||||
vendorMap.set(v.id, v.name)
|
||||
}
|
||||
// Filter for TOM-domain controls
|
||||
const tomControls = instances
|
||||
.filter((ci: any) => ci.domain === 'TOM' || ci.controlId?.startsWith('VND-TOM'))
|
||||
.map((ci: any) => ({
|
||||
vendorId: ci.vendorId || ci.vendor_id,
|
||||
vendorName: vendorMap.get(ci.vendorId || ci.vendor_id) || 'Unbekannt',
|
||||
controlId: ci.controlId || ci.control_id,
|
||||
controlName: ci.controlName || ci.control_name || ci.controlId || ci.control_id,
|
||||
domain: ci.domain || 'TOM',
|
||||
status: ci.status || 'UNKNOWN',
|
||||
lastTestedAt: ci.lastTestedAt || ci.last_tested_at,
|
||||
}))
|
||||
setVendorControls(tomControls)
|
||||
}).catch(() => {})
|
||||
}, [tab])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed / memoised values
|
||||
@@ -316,6 +370,17 @@ export default function TOMPage() {
|
||||
/>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab 5 – TOM-Dokument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderTOMDokument = () => (
|
||||
<TOMDocumentTab
|
||||
state={state}
|
||||
complianceResult={complianceResult}
|
||||
/>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab content router
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -330,6 +395,8 @@ export default function TOMPage() {
|
||||
return renderGenerator()
|
||||
case 'gap-export':
|
||||
return renderGapExport()
|
||||
case 'tom-dokument':
|
||||
return renderTOMDokument()
|
||||
default:
|
||||
return renderUebersicht()
|
||||
}
|
||||
@@ -351,6 +418,60 @@ export default function TOMPage() {
|
||||
|
||||
{/* Active tab content */}
|
||||
<div>{renderActiveTab()}</div>
|
||||
|
||||
{/* Vendor-Controls cross-reference (only on overview tab) */}
|
||||
{tab === 'uebersicht' && vendorControls.length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">Auftragsverarbeiter-Controls (Art. 28)</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">TOM-relevante Controls aus dem Vendor Register</p>
|
||||
</div>
|
||||
<a href="/sdk/vendor-compliance" className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Zum Vendor Register →
|
||||
</a>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Vendor</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Control</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Letzte Pruefung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{vendorControls.map((vc, i) => (
|
||||
<tr key={`${vc.vendorId}-${vc.controlId}-${i}`} className="hover:bg-gray-50">
|
||||
<td className="py-2.5 px-3 font-medium text-gray-900">{vc.vendorName}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="font-mono text-xs text-gray-500">{vc.controlId}</span>
|
||||
<span className="ml-2 text-gray-700">{vc.controlName !== vc.controlId ? vc.controlName : ''}</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
vc.status === 'PASS' ? 'bg-green-100 text-green-700' :
|
||||
vc.status === 'PARTIAL' ? 'bg-yellow-100 text-yellow-700' :
|
||||
vc.status === 'FAIL' ? 'bg-red-100 text-red-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{vc.status === 'PASS' ? 'Bestanden' :
|
||||
vc.status === 'PARTIAL' ? 'Teilweise' :
|
||||
vc.status === 'FAIL' ? 'Nicht bestanden' :
|
||||
vc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-gray-500">
|
||||
{vc.lastTestedAt ? new Date(vc.lastTestedAt).toLocaleDateString('de-DE') : '\u2014'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ interface FullAssessment {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
triggered_rules?: TriggeredRule[]
|
||||
required_controls?: RequiredControl[]
|
||||
recommended_architecture?: PatternRecommendation[]
|
||||
@@ -167,6 +169,8 @@ export default function AssessmentDetailPage() {
|
||||
dsfa_recommended: assessment.dsfa_recommended,
|
||||
art22_risk: assessment.art22_risk,
|
||||
training_allowed: assessment.training_allowed,
|
||||
betrvg_conflict_score: assessment.betrvg_conflict_score,
|
||||
betrvg_consultation_required: assessment.betrvg_consultation_required,
|
||||
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||
rule_code: r.code,
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Assessment {
|
||||
feasibility: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
domain: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -194,6 +196,16 @@ export default function UseCasesPage() {
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||
{feasibility.label}
|
||||
</span>
|
||||
{assessment.betrvg_conflict_score != null && assessment.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
assessment.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
assessment.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
assessment.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR {assessment.betrvg_conflict_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{assessment.domain}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -643,6 +643,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/assertions"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
}
|
||||
label="Assertions"
|
||||
isActive={pathname === '/sdk/assertions'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/dsms"
|
||||
icon={
|
||||
|
||||
@@ -545,8 +545,8 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
'tom': {
|
||||
title: 'Technische und Organisatorische Massnahmen',
|
||||
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
|
||||
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
|
||||
description: 'TOMs nach Art. 32 DSGVO mit Vendor-Controls-Querverweis',
|
||||
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse. Im Uebersicht-Tab werden zusaetzlich Vendor-TOM-Controls (VND-TOM-01 bis VND-TOM-06) aus dem Vendor-Compliance-Modul als Querverweis angezeigt.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
@@ -563,12 +563,17 @@ export const STEP_EXPLANATIONS = {
|
||||
title: 'SDM-Mapping',
|
||||
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Vendor-Controls',
|
||||
description: 'Im Uebersicht-Tab werden Vendor-TOM-Controls (VND-TOM-01 bis 06) als Read-Only-Querverweis angezeigt: Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren Ihrer Auftragsverarbeiter.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'vvt': {
|
||||
title: 'Verarbeitungsverzeichnis',
|
||||
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
|
||||
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
|
||||
description: 'Verarbeitungsverzeichnis nach Art. 30 DSGVO mit integriertem Processor-Tab',
|
||||
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch. Der Tab "Auftragsverarbeiter (Abs. 2)" liest Vendors mit role=PROCESSOR/SUB_PROCESSOR direkt aus der Vendor-Compliance-API — keine doppelte Datenhaltung.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
@@ -585,6 +590,11 @@ export const STEP_EXPLANATIONS = {
|
||||
title: 'Kein oeffentliches Dokument',
|
||||
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Processor-Tab (Art. 30 Abs. 2)',
|
||||
description: 'Auftragsverarbeiter werden direkt aus dem Vendor Register gelesen (Read-Only). Neue Vendors werden im Vendor-Compliance-Modul angelegt und erscheinen hier automatisch. PDF-Druck fuer Art. 30 Abs. 2 Dokument.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-banner': {
|
||||
@@ -611,8 +621,8 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
'obligations': {
|
||||
title: 'Pflichtenuebersicht',
|
||||
description: 'Alle regulatorischen Pflichten auf einen Blick',
|
||||
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. Sie sehen auf einen Blick, welche Pflichten fuer Ihr Unternehmen gelten.',
|
||||
description: 'Regulatorische Pflichten mit 12 Compliance-Checks und Vendor-Verknuepfung',
|
||||
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. 12 automatische Compliance-Checks pruefen Vollstaendigkeit, Fristen, Nachweise und Vendor-Verknuepfungen. Art.-28-Pflichten koennen mit Auftragsverarbeitern aus dem Vendor Register verknuepft werden. Das Pflichtenregister-Dokument (11 Sektionen) kann als auditfaehiges PDF gedruckt werden.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'info' as const,
|
||||
@@ -621,15 +631,25 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Fristen',
|
||||
description: 'Achten Sie auf die Umsetzungsfristen. Einige Pflichten haben feste Deadlines.',
|
||||
title: 'Compliance-Checks',
|
||||
description: '12 automatische Checks: Fehlende Verantwortliche, ueberfaellige Fristen, fehlende Nachweise, keine Rechtsreferenz, stagnierende Regulierungen, nicht gestartete High-Priority-Pflichten, fehlende Vendor-Verknuepfung (Art. 28) u.v.m.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Vendor-Verknuepfung',
|
||||
description: 'Art.-28-Pflichten (Auftragsverarbeitung) koennen direkt mit Vendors aus dem Vendor Register verknuepft werden. Check #12 (MISSING_VENDOR_LINK) warnt bei fehlender Verknuepfung.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Pflichtenregister-Dokument',
|
||||
description: 'Generieren Sie ein auditfaehiges Pflichtenregister mit 11 Sektionen: Ziel, Geltungsbereich, Methodik, Regulatorische Grundlagen, Pflichtenuebersicht, Details, Verantwortlichkeiten, Fristen, Nachweisverzeichnis, Compliance-Status und Aenderungshistorie.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'loeschfristen': {
|
||||
title: 'Loeschfristen',
|
||||
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
|
||||
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
|
||||
description: 'Aufbewahrungsrichtlinien mit VVT-Verknuepfung und Vendor-Zuordnung',
|
||||
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden. Policies koennen mit VVT-Verarbeitungstaetigkeiten und Auftragsverarbeitern aus dem Vendor Register verknuepft werden.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
@@ -646,6 +666,11 @@ export const STEP_EXPLANATIONS = {
|
||||
title: 'Backup-Behandlung',
|
||||
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Vendor-Verknuepfung',
|
||||
description: 'Loeschfrist-Policies koennen mit Auftragsverarbeitern verknuepft werden. So ist dokumentiert, welche Vendors Loeschpflichten fuer bestimmte Datenkategorien haben.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'consent': {
|
||||
@@ -716,6 +741,33 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'vendor-compliance': {
|
||||
title: 'Vendor Compliance',
|
||||
description: 'Auftragsverarbeiter-Management mit Cross-Modul-Integration',
|
||||
explanation: 'Vendor Compliance verwaltet alle Auftragsverarbeiter (Art. 28 DSGVO) und Drittanbieter. Fuer jeden Vendor werden AVVs, Drittlandtransfers, TOMs und Subunternehmer geprueft. Das Modul ist zentral mit vier weiteren Modulen integriert: VVT-Processor-Tab liest Vendors direkt aus der API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids, TOM zeigt Vendor-Controls als Querverweis.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Art. 28 DSGVO',
|
||||
description: 'Jede Auftragsverarbeitung erfordert einen schriftlichen Vertrag (AVV). Pruefen Sie: Weisungsgebundenheit, TOMs, Subunternehmer-Genehmigung, Loeschpflicht und Audit-Recht.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Cross-Modul-Integration',
|
||||
description: 'Vendors erscheinen automatisch im VVT-Processor-Tab, koennen in Obligations und Loeschfristen verknuepft werden, und ihre TOM-Controls werden im TOM-Modul als Querverweis angezeigt.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Drittlandtransfer',
|
||||
description: 'Bei Datenverarbeitung ausserhalb der EU/EWR sind Standardvertragsklauseln (SCCs) oder andere Garantien nach Art. 44-49 DSGVO erforderlich.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Controls Library',
|
||||
description: '6 TOM-Domain Controls (VND-TOM-01 bis VND-TOM-06) pruefen Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren bei Ihren Auftragsverarbeitern.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'document-generator': {
|
||||
title: 'Dokumentengenerator',
|
||||
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
|
||||
|
||||
554
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
Normal file
554
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface DecisionTreeQuestion {
|
||||
id: string
|
||||
axis: 'high_risk' | 'gpai'
|
||||
question: string
|
||||
description: string
|
||||
article_ref: string
|
||||
skip_if?: string
|
||||
}
|
||||
|
||||
interface DecisionTreeDefinition {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
questions: DecisionTreeQuestion[]
|
||||
}
|
||||
|
||||
interface DecisionTreeAnswer {
|
||||
question_id: string
|
||||
value: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface GPAIClassification {
|
||||
is_gpai: boolean
|
||||
is_systemic_risk: boolean
|
||||
gpai_category: 'none' | 'standard' | 'systemic'
|
||||
applicable_articles: string[]
|
||||
obligations: string[]
|
||||
}
|
||||
|
||||
interface DecisionTreeResult {
|
||||
id: string
|
||||
tenant_id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
answers: Record<string, DecisionTreeAnswer>
|
||||
high_risk_result: string
|
||||
gpai_result: GPAIClassification
|
||||
combined_obligations: string[]
|
||||
applicable_articles: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' },
|
||||
high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
|
||||
limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
|
||||
minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
|
||||
not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
}
|
||||
|
||||
const GPAI_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
|
||||
systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export default function DecisionTreeWizard() {
|
||||
const [definition, setDefinition] = useState<DecisionTreeDefinition | null>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const [systemName, setSystemName] = useState('')
|
||||
const [systemDescription, setSystemDescription] = useState('')
|
||||
const [result, setResult] = useState<DecisionTreeResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro')
|
||||
|
||||
// Load decision tree definition
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDefinition(data)
|
||||
} else {
|
||||
setError('Entscheidungsbaum konnte nicht geladen werden')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Get visible questions (respecting skip logic)
|
||||
const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => {
|
||||
if (!definition) return []
|
||||
return definition.questions.filter(q => {
|
||||
if (!q.skip_if) return true
|
||||
// Skip this question if the gate question was answered "no"
|
||||
const gateAnswer = answers[q.skip_if]
|
||||
if (gateAnswer && !gateAnswer.value) return false
|
||||
return true
|
||||
})
|
||||
}, [definition, answers])
|
||||
|
||||
const visibleQuestions = getVisibleQuestions()
|
||||
const currentQuestion = visibleQuestions[currentIdx]
|
||||
const totalVisible = visibleQuestions.length
|
||||
|
||||
const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk')
|
||||
const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai')
|
||||
|
||||
const handleAnswer = (value: boolean) => {
|
||||
if (!currentQuestion) return
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: {
|
||||
question_id: currentQuestion.id,
|
||||
value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Auto-advance
|
||||
if (currentIdx < totalVisible - 1) {
|
||||
setCurrentIdx(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIdx > 0) {
|
||||
setCurrentIdx(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_name: systemName,
|
||||
system_description: systemDescription,
|
||||
answers,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResult(data)
|
||||
setPhase('result')
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' }))
|
||||
setError(err.error || 'Auswertung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setAnswers({})
|
||||
setCurrentIdx(0)
|
||||
setSystemName('')
|
||||
setSystemDescription('')
|
||||
setResult(null)
|
||||
setPhase('intro')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !definition) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<p className="text-red-700">{error}</p>
|
||||
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INTRO PHASE
|
||||
// =========================================================================
|
||||
if (phase === 'intro') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
|
||||
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 51–56).
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z" />
|
||||
</svg>
|
||||
<span className="font-medium text-orange-700">Achse 1: High-Risk</span>
|
||||
</div>
|
||||
<p className="text-sm text-orange-600">7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
<span className="font-medium text-blue-700">Achse 2: GPAI</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600">5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 51–56)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Systems *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={systemName}
|
||||
onChange={e => setSystemName(e.target.value)}
|
||||
placeholder="z.B. Dokumenten-Analyse-KI, Chatbot-Service, Code-Assistent"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
value={systemDescription}
|
||||
onChange={e => setSystemDescription(e.target.value)}
|
||||
placeholder="Kurze Beschreibung des KI-Systems und seines Einsatzzwecks..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setPhase('questions')}
|
||||
disabled={!systemName.trim()}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
systemName.trim()
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Klassifizierung starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RESULT PHASE
|
||||
// =========================================================================
|
||||
if (phase === 'result' && result) {
|
||||
const riskConfig = RISK_LEVEL_CONFIG[result.high_risk_result] || RISK_LEVEL_CONFIG.not_applicable
|
||||
const gpaiConfig = GPAI_CONFIG[result.gpai_result.gpai_category] || GPAI_CONFIG.none
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Klassifizierungsergebnis: {result.system_name}</h3>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Neue Klassifizierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Two-Axis Result Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className={`p-5 rounded-xl border-2 ${riskConfig.border} ${riskConfig.bg}`}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 1: High-Risk (Anhang III)</div>
|
||||
<div className={`text-xl font-bold ${riskConfig.color}`}>{riskConfig.label}</div>
|
||||
</div>
|
||||
<div className={`p-5 rounded-xl border-2 ${gpaiConfig.border} ${gpaiConfig.bg}`}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 2: GPAI (Art. 51–56)</div>
|
||||
<div className={`text-xl font-bold ${gpaiConfig.color}`}>{gpaiConfig.label}</div>
|
||||
{result.gpai_result.is_systemic_risk && (
|
||||
<div className="mt-1 text-xs text-purple-600 font-medium">Systemisches Risiko</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicable Articles */}
|
||||
{result.applicable_articles && result.applicable_articles.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Anwendbare Artikel</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.applicable_articles.map(art => (
|
||||
<span key={art} className="px-3 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-200">
|
||||
{art}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Obligations */}
|
||||
{result.combined_obligations && result.combined_obligations.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Pflichten ({result.combined_obligations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.combined_obligations.map((obl, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{obl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPAI-specific obligations */}
|
||||
{result.gpai_result.is_gpai && result.gpai_result.obligations.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||||
GPAI-spezifische Pflichten ({result.gpai_result.obligations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.gpai_result.obligations.map((obl, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span className="text-blue-800">{obl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Ihre Antworten</h4>
|
||||
<div className="space-y-2">
|
||||
{definition?.questions.map(q => {
|
||||
const answer = result.answers[q.id]
|
||||
if (!answer) return null
|
||||
return (
|
||||
<div key={q.id} className="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0">
|
||||
<span className="text-xs font-mono text-gray-400 w-8">{q.id}</span>
|
||||
<span className="flex-1 text-gray-600">{q.question}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
answer.value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{answer.value ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// QUESTIONS PHASE
|
||||
// =========================================================================
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{systemName} — Frage {currentIdx + 1} von {totalVisible}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
|
||||
currentQuestion?.axis === 'high_risk'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{currentQuestion?.axis === 'high_risk' ? 'High-Risk' : 'GPAI'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dual progress bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-orange-600 mb-1 font-medium">
|
||||
Achse 1: High-Risk ({highRiskQuestions.filter(q => answers[q.id] !== undefined).length}/{highRiskQuestions.length})
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 rounded-full transition-all"
|
||||
style={{ width: `${highRiskQuestions.length ? (highRiskQuestions.filter(q => answers[q.id] !== undefined).length / highRiskQuestions.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-blue-600 mb-1 font-medium">
|
||||
Achse 2: GPAI ({gpaiQuestions.filter(q => answers[q.id] !== undefined).length}/{gpaiQuestions.length})
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${gpaiQuestions.length ? (gpaiQuestions.filter(q => answers[q.id] !== undefined).length / gpaiQuestions.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Question */}
|
||||
{currentQuestion && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="px-2 py-1 text-xs font-mono bg-gray-100 text-gray-500 rounded">{currentQuestion.id}</span>
|
||||
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">{currentQuestion.article_ref}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">{currentQuestion.question}</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">{currentQuestion.description}</p>
|
||||
|
||||
{/* Answer buttons */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleAnswer(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||||
answers[currentQuestion.id]?.value === true
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 hover:border-green-300 hover:bg-green-50/50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAnswer(false)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||||
answers[currentQuestion.id]?.value === false
|
||||
? 'border-gray-500 bg-gray-50 text-gray-700'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={currentIdx === 0 ? () => setPhase('intro') : handleBack}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{visibleQuestions.map((q, i) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setCurrentIdx(i)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-colors ${
|
||||
i === currentIdx
|
||||
? q.axis === 'high_risk' ? 'bg-orange-500' : 'bg-blue-500'
|
||||
: answers[q.id] !== undefined
|
||||
? 'bg-green-400'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
title={`${q.id}: ${q.question}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allAnswered ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
saving
|
||||
? 'bg-purple-300 text-white cursor-wait'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Auswertung...
|
||||
</span>
|
||||
) : (
|
||||
'Auswerten'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCurrentIdx(prev => Math.min(prev + 1, totalVisible - 1))}
|
||||
disabled={currentIdx >= totalVisible - 1}
|
||||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-30"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
|
||||
import {
|
||||
buildObligationDocumentHtml,
|
||||
createDefaultObligationDocumentOrgHeader,
|
||||
type ObligationDocumentOrgHeader,
|
||||
type ObligationDocumentRevision,
|
||||
} from '@/lib/sdk/obligations-document'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface ObligationDocumentTabProps {
|
||||
obligations: Obligation[]
|
||||
complianceResult: ObligationComplianceCheckResult | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function ObligationDocumentTab({ obligations, complianceResult }: ObligationDocumentTabProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [orgHeader, setOrgHeader] = useState<ObligationDocumentOrgHeader>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_obligation_document_orgheader')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return createDefaultObligationDocumentOrgHeader()
|
||||
})
|
||||
|
||||
const [revisions, setRevisions] = useState<ObligationDocumentRevision[]>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_obligation_document_revisions')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_obligation_document_orgheader', JSON.stringify(orgHeader))
|
||||
}, [orgHeader])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_obligation_document_revisions', JSON.stringify(revisions))
|
||||
}, [revisions])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const obligationCount = obligations.length
|
||||
|
||||
const completedCount = useMemo(() => {
|
||||
return obligations.filter(o => o.status === 'completed').length
|
||||
}, [obligations])
|
||||
|
||||
const distinctSources = useMemo(() => {
|
||||
const sources = new Set(obligations.map(o => o.source || 'Sonstig'))
|
||||
return sources.size
|
||||
}, [obligations])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handlePrintDocument = useCallback(() => {
|
||||
const html = buildObligationDocumentHtml(
|
||||
obligations,
|
||||
orgHeader,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html)
|
||||
printWindow.document.close()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}, [obligations, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleDownloadDocumentHtml = useCallback(() => {
|
||||
const html = buildObligationDocumentHtml(
|
||||
obligations,
|
||||
orgHeader,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Pflichtenregister-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [obligations, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleAddRevision = useCallback(() => {
|
||||
setRevisions(prev => [...prev, {
|
||||
version: String(prev.length + 1) + '.0',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
author: '',
|
||||
changes: '',
|
||||
}])
|
||||
}, [])
|
||||
|
||||
const handleUpdateRevision = useCallback((index: number, field: keyof ObligationDocumentRevision, value: string) => {
|
||||
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
|
||||
}, [])
|
||||
|
||||
const handleRemoveRevision = useCallback((index: number) => {
|
||||
setRevisions(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const updateOrgHeader = useCallback((field: keyof ObligationDocumentOrgHeader, value: string) => {
|
||||
setOrgHeader(prev => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Action Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Pflichtenregister</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Auditfaehiges Dokument mit {obligationCount} Pflichten generieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDownloadDocumentHtml}
|
||||
disabled={obligationCount === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintDocument}
|
||||
disabled={obligationCount === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Als PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.organizationName}
|
||||
onChange={e => updateOrgHeader('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.industry}
|
||||
onChange={e => updateOrgHeader('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoName}
|
||||
onChange={e => updateOrgHeader('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoContact}
|
||||
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.responsiblePerson}
|
||||
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsabteilung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.legalDepartment}
|
||||
onChange={e => updateOrgHeader('legalDepartment', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.documentVersion}
|
||||
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.reviewInterval}
|
||||
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.lastReviewDate}
|
||||
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.nextReviewDate}
|
||||
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Revisions Manager */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button
|
||||
onClick={handleAddRevision}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
+ Version hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revisions.map((revision, index) => (
|
||||
<tr key={index} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.version}
|
||||
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="date"
|
||||
value={revision.date}
|
||||
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.author}
|
||||
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.changes}
|
||||
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
|
||||
placeholder="Beschreibung der Aenderungen"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveRevision(index)}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
{obligationCount === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Erfassen Sie Pflichten, um das Pflichtenregister zu generieren.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Cover preview */}
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p className="text-purple-700 font-semibold text-lg">Pflichtenregister</p>
|
||||
<p className="text-purple-600 text-sm">
|
||||
Regulatorische Pflichten — {orgHeader.organizationName || 'Organisation'}
|
||||
</p>
|
||||
<p className="text-purple-500 text-xs mt-1">
|
||||
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{obligationCount}</p>
|
||||
<p className="text-xs text-gray-500">Pflichten</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{completedCount}</p>
|
||||
<p className="text-xs text-gray-500">Abgeschlossen</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-purple-700">{distinctSources}</p>
|
||||
<p className="text-xs text-gray-500">Regulierungen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||
<p className="text-xs text-gray-500">Sektionen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{complianceResult ? complianceResult.score : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Compliance-Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 12 Sections list */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
<li>Ziel und Zweck</li>
|
||||
<li>Geltungsbereich</li>
|
||||
<li>Methodik</li>
|
||||
<li>Regulatorische Grundlagen</li>
|
||||
<li>Pflichtenuebersicht</li>
|
||||
<li>Detaillierte Pflichten</li>
|
||||
<li>Verantwortlichkeiten</li>
|
||||
<li>Fristen und Termine</li>
|
||||
<li>Nachweisverzeichnis</li>
|
||||
<li>Compliance-Status</li>
|
||||
<li>Aenderungshistorie</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ObligationDocumentTab }
|
||||
449
admin-compliance/components/sdk/tom-dashboard/TOMDocumentTab.tsx
Normal file
449
admin-compliance/components/sdk/tom-dashboard/TOMDocumentTab.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type { TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import type { TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
|
||||
import {
|
||||
buildTOMDocumentHtml,
|
||||
createDefaultTOMDocumentOrgHeader,
|
||||
type TOMDocumentOrgHeader,
|
||||
type TOMDocumentRevision,
|
||||
} from '@/lib/sdk/tom-document'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface TOMDocumentTabProps {
|
||||
state: TOMGeneratorState
|
||||
complianceResult: TOMComplianceCheckResult | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function TOMDocumentTab({ state, complianceResult }: TOMDocumentTabProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [orgHeader, setOrgHeader] = useState<TOMDocumentOrgHeader>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_tom_document_orgheader')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return createDefaultTOMDocumentOrgHeader()
|
||||
})
|
||||
|
||||
const [revisions, setRevisions] = useState<TOMDocumentRevision[]>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_tom_document_revisions')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_tom_document_orgheader', JSON.stringify(orgHeader))
|
||||
}, [orgHeader])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_tom_document_revisions', JSON.stringify(revisions))
|
||||
}, [revisions])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tomCount = useMemo(() => {
|
||||
if (!state?.derivedTOMs) return 0
|
||||
return Array.isArray(state.derivedTOMs) ? state.derivedTOMs.length : 0
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
const applicableTOMs = useMemo(() => {
|
||||
if (!state?.derivedTOMs || !Array.isArray(state.derivedTOMs)) return []
|
||||
return state.derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
const implementedCount = useMemo(() => {
|
||||
return applicableTOMs.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
}, [applicableTOMs])
|
||||
|
||||
const [canonicalCount, setCanonicalCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (tomCount === 0) return
|
||||
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.sync_state?.canonical_controls_matched) setCanonicalCount(data.sync_state.canonical_controls_matched) })
|
||||
.catch(() => {})
|
||||
}, [tomCount])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handlePrintTOMDocument = useCallback(() => {
|
||||
const html = buildTOMDocumentHtml(
|
||||
state?.derivedTOMs || [],
|
||||
orgHeader,
|
||||
state?.companyProfile || null,
|
||||
state?.riskProfile || null,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html)
|
||||
printWindow.document.close()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}, [state, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleDownloadTOMDocumentHtml = useCallback(() => {
|
||||
const html = buildTOMDocumentHtml(
|
||||
state?.derivedTOMs || [],
|
||||
orgHeader,
|
||||
state?.companyProfile || null,
|
||||
state?.riskProfile || null,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `TOM-Dokumentation-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [state, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleAddRevision = useCallback(() => {
|
||||
setRevisions(prev => [...prev, {
|
||||
version: String(prev.length + 1) + '.0',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
author: '',
|
||||
changes: '',
|
||||
}])
|
||||
}, [])
|
||||
|
||||
const handleUpdateRevision = useCallback((index: number, field: keyof TOMDocumentRevision, value: string) => {
|
||||
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
|
||||
}, [])
|
||||
|
||||
const handleRemoveRevision = useCallback((index: number) => {
|
||||
setRevisions(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const updateOrgHeader = useCallback((field: keyof TOMDocumentOrgHeader, value: string | string[]) => {
|
||||
setOrgHeader(prev => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Action Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">TOM-Dokument (Art. 32 DSGVO)</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Auditfaehiges Dokument mit {applicableTOMs.length} Massnahmen generieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDownloadTOMDocumentHtml}
|
||||
disabled={tomCount === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintTOMDocument}
|
||||
disabled={tomCount === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Als PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.organizationName}
|
||||
onChange={e => updateOrgHeader('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.industry}
|
||||
onChange={e => updateOrgHeader('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoName}
|
||||
onChange={e => updateOrgHeader('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoContact}
|
||||
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.responsiblePerson}
|
||||
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IT-Sicherheitskontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.itSecurityContact}
|
||||
onChange={e => updateOrgHeader('itSecurityContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitarbeiteranzahl</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.employeeCount}
|
||||
onChange={e => updateOrgHeader('employeeCount', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Standorte</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.locations.join(', ')}
|
||||
onChange={e => updateOrgHeader('locations', e.target.value.split(',').map(s => s.trim()))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.documentVersion}
|
||||
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.reviewInterval}
|
||||
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.lastReviewDate}
|
||||
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.nextReviewDate}
|
||||
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Revisions Manager */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button
|
||||
onClick={handleAddRevision}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
+ Version hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revisions.map((revision, index) => (
|
||||
<tr key={index} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.version}
|
||||
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="date"
|
||||
value={revision.date}
|
||||
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.author}
|
||||
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.changes}
|
||||
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
|
||||
placeholder="Beschreibung der Aenderungen"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveRevision(index)}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
{tomCount === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Starten Sie den TOM-Generator, um Massnahmen abzuleiten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Cover preview */}
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p className="text-purple-700 font-semibold text-lg">TOM-Dokumentation</p>
|
||||
<p className="text-purple-600 text-sm">
|
||||
Art. 32 DSGVO — {orgHeader.organizationName || 'Organisation'}
|
||||
</p>
|
||||
<p className="text-purple-500 text-xs mt-1">
|
||||
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{applicableTOMs.length}</p>
|
||||
<p className="text-xs text-gray-500">Massnahmen</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{implementedCount}</p>
|
||||
<p className="text-xs text-gray-500">Umgesetzt</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-purple-700">{canonicalCount || '-'}</p>
|
||||
<p className="text-xs text-gray-500">Belegende Controls</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||
<p className="text-xs text-gray-500">Sektionen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{complianceResult ? complianceResult.score : '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Compliance-Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 12 Sections list */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
<li>Ziel und Zweck</li>
|
||||
<li>Geltungsbereich</li>
|
||||
<li>Grundprinzipien Art. 32</li>
|
||||
<li>Schutzbedarf und Risikoanalyse</li>
|
||||
<li>Massnahmen-Uebersicht</li>
|
||||
<li>Detaillierte Massnahmen</li>
|
||||
<li>SDM Gewaehrleistungsziele</li>
|
||||
<li>Verantwortlichkeiten</li>
|
||||
<li>Pruef- und Revisionszyklus</li>
|
||||
<li>Compliance-Status</li>
|
||||
<li>Aenderungshistorie</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TOMDocumentTab }
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
|
||||
interface CanonicalMapping {
|
||||
id: string
|
||||
canonical_control_code: string
|
||||
canonical_title: string | null
|
||||
canonical_severity: string | null
|
||||
canonical_objective: string | null
|
||||
mapping_type: string
|
||||
}
|
||||
|
||||
interface TOMEditorTabProps {
|
||||
state: TOMGeneratorState
|
||||
selectedTOMId: string | null
|
||||
@@ -46,6 +55,17 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
|
||||
const [notes, setNotes] = useState('')
|
||||
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
|
||||
const [canonicalMappings, setCanonicalMappings] = useState<CanonicalMapping[]>([])
|
||||
const [showCanonical, setShowCanonical] = useState(false)
|
||||
|
||||
// Load canonical controls for this TOM's category
|
||||
useEffect(() => {
|
||||
if (!control?.category) { setCanonicalMappings([]); return }
|
||||
fetch(`/api/sdk/v1/compliance/tom-mappings/by-tom/${encodeURIComponent(control.category)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.mappings) setCanonicalMappings(data.mappings) })
|
||||
.catch(() => setCanonicalMappings([]))
|
||||
}, [control?.category])
|
||||
|
||||
useEffect(() => {
|
||||
if (tom) {
|
||||
@@ -341,6 +361,62 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canonical Controls (Belegende Security-Controls) */}
|
||||
{canonicalMappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
Belegende Security-Controls ({canonicalMappings.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCanonical(!showCanonical)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
{showCanonical ? 'Einklappen' : 'Alle anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(showCanonical ? canonicalMappings : canonicalMappings.slice(0, 5)).map(m => (
|
||||
<div key={m.id} className="flex items-start gap-3 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs font-mono bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
|
||||
{m.canonical_control_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 font-medium truncate">{m.canonical_title || m.canonical_control_code}</p>
|
||||
{m.canonical_objective && showCanonical && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{m.canonical_objective}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1.5">
|
||||
{m.canonical_severity && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
m.canonical_severity === 'critical' ? 'bg-red-100 text-red-700' :
|
||||
m.canonical_severity === 'high' ? 'bg-orange-100 text-orange-700' :
|
||||
m.canonical_severity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{m.canonical_severity}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
m.mapping_type === 'manual' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{m.mapping_type === 'manual' ? 'manuell' : 'auto'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!showCanonical && canonicalMappings.length > 5 && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
+ {canonicalMappings.length - 5} weitere Controls
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Framework Mappings */}
|
||||
{control?.mappings && control.mappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
|
||||
@@ -11,6 +11,18 @@ interface TOMOverviewTabProps {
|
||||
onStartGenerator: () => void
|
||||
}
|
||||
|
||||
interface MappingStats {
|
||||
sync_state: {
|
||||
profile_hash: string | null
|
||||
total_mappings: number
|
||||
canonical_controls_matched: number
|
||||
tom_controls_covered: number
|
||||
last_synced_at: string | null
|
||||
}
|
||||
category_breakdown: { tom_category: string; total_mappings: number; unique_controls: number }[]
|
||||
total_canonical_controls_available: number
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
|
||||
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
|
||||
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
|
||||
@@ -34,9 +46,41 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
|
||||
const [typeFilter, setTypeFilter] = useState<string>('ALL')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL')
|
||||
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
|
||||
const [mappingStats, setMappingStats] = useState<MappingStats | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const categories = useMemo(() => getAllCategories(), [])
|
||||
|
||||
// Load mapping stats
|
||||
useEffect(() => {
|
||||
if (state.derivedTOMs.length === 0) return
|
||||
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setMappingStats(data) })
|
||||
.catch(() => {})
|
||||
}, [state.derivedTOMs.length])
|
||||
|
||||
const handleSyncControls = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/compliance/tom-mappings/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
industry: state.companyProfile?.industry || null,
|
||||
company_size: state.companyProfile?.size || null,
|
||||
force: false,
|
||||
}),
|
||||
})
|
||||
if (resp.ok) {
|
||||
// Reload stats after sync
|
||||
const statsResp = await fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
if (statsResp.ok) setMappingStats(await statsResp.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setSyncing(false)
|
||||
}, [state.companyProfile])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const toms = state.derivedTOMs
|
||||
return {
|
||||
@@ -159,6 +203,59 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canonical Control Library Coverage */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Canonical Control Library</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Belegende Security-Controls aus OWASP, NIST, ENISA
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSyncControls}
|
||||
disabled={syncing}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
|
||||
>
|
||||
{syncing ? 'Synchronisiere...' : 'Controls synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
{mappingStats ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.total_mappings}</div>
|
||||
<div className="text-xs text-gray-500">Zugeordnete Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-purple-600">{mappingStats.sync_state.canonical_controls_matched}</div>
|
||||
<div className="text-xs text-gray-500">Einzigartige Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.tom_controls_covered}/13</div>
|
||||
<div className="text-xs text-gray-500">Kategorien abgedeckt</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{mappingStats.total_canonical_controls_available}</div>
|
||||
<div className="text-xs text-gray-500">Verfuegbare Controls</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Noch keine Controls synchronisiert. Klicken Sie "Controls synchronisieren", um relevante
|
||||
Security-Controls aus der Canonical Control Library zuzuordnen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mappingStats?.sync_state?.last_synced_at && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Letzte Synchronisation: {new Date(mappingStats.sync_state.last_synced_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
export { TOMDocumentTab } from './TOMDocumentTab'
|
||||
|
||||
@@ -10,6 +10,8 @@ interface AssessmentResult {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
summary: string
|
||||
recommendation: string
|
||||
alternative_approach?: string
|
||||
@@ -76,6 +78,21 @@ export function AssessmentResultCard({ result }: AssessmentResultCardProps) {
|
||||
Art. 22 Risiko
|
||||
</span>
|
||||
)}
|
||||
{result.betrvg_conflict_score != null && result.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
result.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
result.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
result.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR-Konflikt: {result.betrvg_conflict_score}/100
|
||||
</span>
|
||||
)}
|
||||
{result.betrvg_consultation_required && (
|
||||
<span className="px-3 py-1 rounded-full text-sm bg-purple-100 text-purple-700">
|
||||
BR-Konsultation erforderlich
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-700">{result.summary}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>
|
||||
|
||||
510
admin-compliance/lib/sdk/__tests__/vvt-scope-integration.test.ts
Normal file
510
admin-compliance/lib/sdk/__tests__/vvt-scope-integration.test.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Integration Tests: Company Profile → Compliance Scope → VVT Generator
|
||||
*
|
||||
* Tests the complete data pipeline from Company Profile master data
|
||||
* through the Compliance Scope Engine to VVT activity generation.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
prefillFromCompanyProfile,
|
||||
exportToVVTAnswers,
|
||||
getAutoFilledScoringAnswers,
|
||||
SCOPE_QUESTION_BLOCKS,
|
||||
} from '../compliance-scope-profiling'
|
||||
import {
|
||||
generateActivities,
|
||||
PROFILING_QUESTIONS,
|
||||
DEPARTMENT_DATA_CATEGORIES,
|
||||
SCOPE_PREFILLED_VVT_QUESTIONS,
|
||||
} from '../vvt-profiling'
|
||||
import type { ScopeProfilingAnswer } from '../compliance-scope-types'
|
||||
|
||||
// Helper
|
||||
function ans(questionId: string, value: unknown): ScopeProfilingAnswer {
|
||||
return { questionId, value } as ScopeProfilingAnswer
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. Company Profile → Scope Prefill
|
||||
// =============================================================================
|
||||
|
||||
describe('CompanyProfile → Scope prefill', () => {
|
||||
it('prefills org_has_dsb when dpoName is set', () => {
|
||||
const profile = { dpoName: 'Max Mustermann' } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_has_dsb')?.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT prefill org_has_dsb when dpoName is empty', () => {
|
||||
const profile = { dpoName: '' } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_has_dsb')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps offerings to prod_type correctly', () => {
|
||||
const profile = { offerings: ['WebApp', 'SaaS', 'API'] } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
const prodType = answers.find((a) => a.questionId === 'prod_type')
|
||||
expect(prodType?.value).toEqual(expect.arrayContaining(['webapp', 'saas', 'api']))
|
||||
})
|
||||
|
||||
it('detects webshop in offerings', () => {
|
||||
const profile = { offerings: ['Webshop'] } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers.find((a) => a.questionId === 'prod_webshop')?.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty array when profile has no relevant data', () => {
|
||||
const profile = {} as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompanyProfile → Scope scoring answers', () => {
|
||||
it('maps employeeCount to org_employee_count', () => {
|
||||
const profile = { employeeCount: '50-249' } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_employee_count')?.value).toBe('50-249')
|
||||
})
|
||||
|
||||
it('maps industry to org_industry', () => {
|
||||
const profile = { industry: ['IT & Software', 'Finanzdienstleistungen'] } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_industry')?.value).toBe(
|
||||
'IT & Software, Finanzdienstleistungen'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps annualRevenue to org_annual_revenue', () => {
|
||||
const profile = { annualRevenue: '1-10M' } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_annual_revenue')?.value).toBe('1-10M')
|
||||
})
|
||||
|
||||
it('maps businessModel to org_business_model', () => {
|
||||
const profile = { businessModel: 'B2B' } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_business_model')?.value).toBe('B2B')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 2. Scope → VVT Answer Mapping (exportToVVTAnswers)
|
||||
// =============================================================================
|
||||
|
||||
describe('Scope → VVT answer export', () => {
|
||||
it('maps scope questions with mapsToVVTQuestion property', () => {
|
||||
// Block 9: dk_dept_hr maps to dept_hr_categories
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('dk_dept_hr', ['NAME', 'SALARY_DATA', 'HEALTH_DATA']),
|
||||
]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'SALARY_DATA', 'HEALTH_DATA'])
|
||||
})
|
||||
|
||||
it('maps multiple department data categories', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('dk_dept_hr', ['NAME', 'BANK_ACCOUNT']),
|
||||
ans('dk_dept_finance', ['INVOICE_DATA', 'TAX_ID']),
|
||||
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA']),
|
||||
]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'BANK_ACCOUNT'])
|
||||
expect(vvtAnswers.dept_finance_categories).toEqual(['INVOICE_DATA', 'TAX_ID'])
|
||||
expect(vvtAnswers.dept_marketing_categories).toEqual(['EMAIL', 'TRACKING_DATA'])
|
||||
})
|
||||
|
||||
it('ignores scope questions without mapsToVVTQuestion', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('vvt_has_vvt', true), // No mapsToVVTQuestion property
|
||||
]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(Object.keys(vvtAnswers)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles empty scope answers', () => {
|
||||
const vvtAnswers = exportToVVTAnswers([])
|
||||
expect(vvtAnswers).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 3. Scope → VVT Profiling Prefill
|
||||
// Note: prefillFromScopeAnswers() uses dynamic require('./compliance-scope-profiling')
|
||||
// which doesn't resolve in vitest. We test the same pipeline by calling
|
||||
// exportToVVTAnswers() directly (which is what prefillFromScopeAnswers wraps).
|
||||
// =============================================================================
|
||||
|
||||
describe('Scope → VVT Profiling Prefill (via exportToVVTAnswers)', () => {
|
||||
it('converts scope answers to VVT ProfilingAnswers format', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA']),
|
||||
ans('dk_dept_finance', ['BANK_ACCOUNT']),
|
||||
]
|
||||
const exported = exportToVVTAnswers(scopeAnswers)
|
||||
// Same transformation as prefillFromScopeAnswers
|
||||
const profiling: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) profiling[key] = value
|
||||
}
|
||||
expect(profiling.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA'])
|
||||
expect(profiling.dept_finance_categories).toEqual(['BANK_ACCOUNT'])
|
||||
})
|
||||
|
||||
it('filters out null/undefined values', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', null)]
|
||||
const exported = exportToVVTAnswers(scopeAnswers)
|
||||
const profiling: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) profiling[key] = value
|
||||
}
|
||||
expect(profiling.dept_hr_categories).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 4. VVT Generator — generateActivities
|
||||
// =============================================================================
|
||||
|
||||
describe('generateActivities', () => {
|
||||
it('always generates 4 IT baseline activities', () => {
|
||||
const result = generateActivities({})
|
||||
const names = result.generatedActivities.map((a) => a.name)
|
||||
expect(result.generatedActivities.length).toBeGreaterThanOrEqual(4)
|
||||
// IT baselines are always added
|
||||
const itTemplates = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'it_operations'
|
||||
)
|
||||
expect(itTemplates.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('triggers HR templates when dept_hr=true', () => {
|
||||
const result = generateActivities({ dept_hr: true })
|
||||
const hrActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'hr'
|
||||
)
|
||||
expect(hrActivities.length).toBeGreaterThanOrEqual(3) // mitarbeiter, gehalt, zeiterfassung
|
||||
})
|
||||
|
||||
it('triggers finance templates when dept_finance=true', () => {
|
||||
const result = generateActivities({ dept_finance: true })
|
||||
const financeActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'finance'
|
||||
)
|
||||
expect(financeActivities.length).toBeGreaterThanOrEqual(2) // buchhaltung, zahlungsverkehr
|
||||
})
|
||||
|
||||
it('enriches activities with US cloud third-country transfer', () => {
|
||||
const result = generateActivities({ dept_hr: true, transfer_cloud_us: true })
|
||||
// Every activity should have a US third-country transfer
|
||||
for (const activity of result.generatedActivities) {
|
||||
expect(activity.thirdCountryTransfers.some((t) => t.country === 'US')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('adds HEALTH_DATA to HR activities when data_health=true', () => {
|
||||
const result = generateActivities({ dept_hr: true, data_health: true })
|
||||
const hrActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'hr'
|
||||
)
|
||||
expect(hrActivities.length).toBeGreaterThan(0)
|
||||
for (const hr of hrActivities) {
|
||||
expect(hr.personalDataCategories).toContain('HEALTH_DATA')
|
||||
}
|
||||
})
|
||||
|
||||
it('calculates Art. 30 Abs. 5 exemption correctly', () => {
|
||||
// < 250 employees, no special categories → exempt
|
||||
const result1 = generateActivities({ org_employees: 50 })
|
||||
expect(result1.art30Abs5Exempt).toBe(true)
|
||||
|
||||
// >= 250 employees → not exempt
|
||||
const result2 = generateActivities({ org_employees: 500 })
|
||||
expect(result2.art30Abs5Exempt).toBe(false)
|
||||
|
||||
// < 250 but with special categories → not exempt
|
||||
const result3 = generateActivities({ org_employees: 50, data_health: true })
|
||||
expect(result3.art30Abs5Exempt).toBe(false)
|
||||
})
|
||||
|
||||
it('generates unique VVT IDs for all activities', () => {
|
||||
const result = generateActivities({
|
||||
dept_hr: true,
|
||||
dept_finance: true,
|
||||
dept_sales: true,
|
||||
dept_marketing: true,
|
||||
})
|
||||
const ids = result.generatedActivities.map((a) => a.vvtId)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('calculates coverage score > 0 for template-generated activities', () => {
|
||||
const result = generateActivities({ dept_hr: true })
|
||||
expect(result.coverageScore).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 5. Full Pipeline: Company Profile → Scope → VVT
|
||||
// =============================================================================
|
||||
|
||||
describe('Full Pipeline: CompanyProfile → Scope → VVT Generation', () => {
|
||||
// Helper: replicate what prefillFromScopeAnswers does (avoiding dynamic require)
|
||||
function scopeToProfilingAnswers(
|
||||
scopeAnswers: ScopeProfilingAnswer[]
|
||||
): Record<string, string | string[] | number | boolean> {
|
||||
const exported = exportToVVTAnswers(scopeAnswers)
|
||||
const profiling: Record<string, string | string[] | number | boolean> = {}
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
profiling[key] = value as string | string[] | number | boolean
|
||||
}
|
||||
}
|
||||
return profiling
|
||||
}
|
||||
|
||||
it('complete flow: profile with DSB → scope prefill → VVT generation', () => {
|
||||
// Step 1: Company Profile
|
||||
const profile = {
|
||||
dpoName: 'Dr. Datenschutz',
|
||||
employeeCount: '50-249',
|
||||
industry: ['IT & Software'],
|
||||
offerings: ['WebApp', 'SaaS'],
|
||||
} as any
|
||||
|
||||
// Step 2: Prefill scope from profile
|
||||
const profileAnswers = prefillFromCompanyProfile(profile)
|
||||
const scoringAnswers = getAutoFilledScoringAnswers(profile)
|
||||
|
||||
// Simulate user answering scope questions + auto-prefilled from profile
|
||||
const userAnswers: ScopeProfilingAnswer[] = [
|
||||
// Block 8: departments
|
||||
ans('vvt_departments', ['personal', 'finanzen', 'it']),
|
||||
// Block 9: data categories per department
|
||||
ans('dk_dept_hr', ['NAME', 'ADDRESS', 'SALARY_DATA', 'HEALTH_DATA']),
|
||||
ans('dk_dept_finance', ['NAME', 'BANK_ACCOUNT', 'INVOICE_DATA', 'TAX_ID']),
|
||||
ans('dk_dept_it', ['USER_ACCOUNTS', 'LOG_DATA', 'DEVICE_DATA']),
|
||||
// Block 2: data types
|
||||
ans('data_art9', true),
|
||||
ans('data_minors', false),
|
||||
]
|
||||
|
||||
const allScopeAnswers = [...profileAnswers, ...scoringAnswers, ...userAnswers]
|
||||
|
||||
// Step 3: Export to VVT format
|
||||
const vvtAnswers = exportToVVTAnswers(allScopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual([
|
||||
'NAME',
|
||||
'ADDRESS',
|
||||
'SALARY_DATA',
|
||||
'HEALTH_DATA',
|
||||
])
|
||||
expect(vvtAnswers.dept_finance_categories).toEqual([
|
||||
'NAME',
|
||||
'BANK_ACCOUNT',
|
||||
'INVOICE_DATA',
|
||||
'TAX_ID',
|
||||
])
|
||||
|
||||
// Step 4: Prefill VVT profiling from scope (via direct export)
|
||||
const profilingAnswers = scopeToProfilingAnswers(allScopeAnswers)
|
||||
|
||||
// Verify data survived the transformation
|
||||
expect(profilingAnswers.dept_hr_categories).toEqual([
|
||||
'NAME',
|
||||
'ADDRESS',
|
||||
'SALARY_DATA',
|
||||
'HEALTH_DATA',
|
||||
])
|
||||
|
||||
// Step 5: Generate VVT activities
|
||||
// Add department triggers that match Block 8 selections
|
||||
profilingAnswers.dept_hr = true
|
||||
profilingAnswers.dept_finance = true
|
||||
|
||||
const result = generateActivities(profilingAnswers)
|
||||
|
||||
// Verify activities were generated
|
||||
expect(result.generatedActivities.length).toBeGreaterThan(4) // 4 IT baseline + HR + Finance
|
||||
|
||||
// Verify HR activities exist
|
||||
const hrActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'hr'
|
||||
)
|
||||
expect(hrActivities.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Verify finance activities exist
|
||||
const financeActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'finance'
|
||||
)
|
||||
expect(financeActivities.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('end-to-end: departments selected in scope generate correct VVT activities', () => {
|
||||
// Simulate a complete scope session with department selections
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
// Block 2: data_art9 maps to data_health in VVT
|
||||
ans('data_art9', true),
|
||||
// Block 4: tech_third_country maps to transfer_cloud_us
|
||||
ans('tech_third_country', true),
|
||||
// Block 8: departments
|
||||
ans('vvt_departments', ['personal', 'marketing', 'kundenservice']),
|
||||
// Block 9: per-department data categories
|
||||
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS']),
|
||||
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA', 'CONSENT_DATA']),
|
||||
ans('dk_dept_support', ['NAME', 'TICKET_DATA', 'COMMUNICATION_DATA']),
|
||||
]
|
||||
|
||||
// Transform to VVT answers
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
|
||||
// Verify Block 9 data categories are mapped correctly
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS'])
|
||||
expect(vvtAnswers.dept_marketing_categories).toEqual([
|
||||
'EMAIL',
|
||||
'TRACKING_DATA',
|
||||
'CONSENT_DATA',
|
||||
])
|
||||
expect(vvtAnswers.dept_support_categories).toEqual([
|
||||
'NAME',
|
||||
'TICKET_DATA',
|
||||
'COMMUNICATION_DATA',
|
||||
])
|
||||
|
||||
// Verify the full pipeline using direct export
|
||||
const profilingAnswers = scopeToProfilingAnswers(scopeAnswers)
|
||||
expect(profilingAnswers.dept_hr_categories).toBeDefined()
|
||||
expect(profilingAnswers.dept_marketing_categories).toBeDefined()
|
||||
expect(profilingAnswers.dept_support_categories).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 6. DEPARTMENT_DATA_CATEGORIES Integrity
|
||||
// =============================================================================
|
||||
|
||||
describe('DEPARTMENT_DATA_CATEGORIES consistency', () => {
|
||||
it('all 12 departments are defined', () => {
|
||||
const expected = [
|
||||
'dept_hr',
|
||||
'dept_recruiting',
|
||||
'dept_finance',
|
||||
'dept_sales',
|
||||
'dept_marketing',
|
||||
'dept_support',
|
||||
'dept_it',
|
||||
'dept_recht',
|
||||
'dept_produktion',
|
||||
'dept_logistik',
|
||||
'dept_einkauf',
|
||||
'dept_facility',
|
||||
]
|
||||
for (const dept of expected) {
|
||||
expect(DEPARTMENT_DATA_CATEGORIES[dept]).toBeDefined()
|
||||
expect(DEPARTMENT_DATA_CATEGORIES[dept].categories.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('every department has a label and icon', () => {
|
||||
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
|
||||
expect(dept.label).toBeTruthy()
|
||||
expect(dept.icon).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('every category has id and label', () => {
|
||||
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
|
||||
for (const cat of dept.categories) {
|
||||
expect(cat.id).toBeTruthy()
|
||||
expect(cat.label).toBeTruthy()
|
||||
expect(cat.info).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Art. 9 categories are correctly flagged', () => {
|
||||
const art9Categories = [
|
||||
{ dept: 'dept_hr', id: 'HEALTH_DATA' },
|
||||
{ dept: 'dept_hr', id: 'RELIGIOUS_BELIEFS' },
|
||||
{ dept: 'dept_recruiting', id: 'HEALTH_DATA' },
|
||||
{ dept: 'dept_recht', id: 'CRIMINAL_DATA' },
|
||||
{ dept: 'dept_produktion', id: 'HEALTH_DATA' },
|
||||
{ dept: 'dept_facility', id: 'HEALTH_DATA' },
|
||||
]
|
||||
|
||||
for (const { dept, id } of art9Categories) {
|
||||
const cat = DEPARTMENT_DATA_CATEGORIES[dept].categories.find((c) => c.id === id)
|
||||
expect(cat?.isArt9).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 7. Block 9 ↔ VVT Mapping Integrity
|
||||
// =============================================================================
|
||||
|
||||
describe('Block 9 Scope ↔ VVT question mapping', () => {
|
||||
it('every Block 9 question has mapsToVVTQuestion', () => {
|
||||
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
|
||||
expect(block9).toBeDefined()
|
||||
|
||||
for (const q of block9!.questions) {
|
||||
expect(q.mapsToVVTQuestion).toBeTruthy()
|
||||
expect(q.mapsToVVTQuestion).toMatch(/^dept_\w+_categories$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('Block 9 question options match DEPARTMENT_DATA_CATEGORIES', () => {
|
||||
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
|
||||
expect(block9).toBeDefined()
|
||||
|
||||
// dk_dept_hr should have same options as DEPARTMENT_DATA_CATEGORIES.dept_hr
|
||||
const hrQuestion = block9!.questions.find((q) => q.id === 'dk_dept_hr')
|
||||
expect(hrQuestion).toBeDefined()
|
||||
|
||||
const expectedIds = DEPARTMENT_DATA_CATEGORIES.dept_hr.categories.map((c) => c.id)
|
||||
const actualIds = hrQuestion!.options!.map((o) => o.value)
|
||||
expect(actualIds).toEqual(expectedIds)
|
||||
})
|
||||
|
||||
it('SCOPE_PREFILLED_VVT_QUESTIONS lists all cross-module questions', () => {
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('org_industry')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('dept_hr')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('data_health')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('transfer_cloud_us')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS.length).toBeGreaterThanOrEqual(15)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 8. Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('generateActivities with no answers still produces IT baselines', () => {
|
||||
const result = generateActivities({})
|
||||
expect(result.generatedActivities.length).toBe(4) // 4 IT baselines
|
||||
expect(result.art30Abs5Exempt).toBe(true) // 0 employees, no special categories
|
||||
})
|
||||
|
||||
it('same template triggered by multiple questions is only generated once', () => {
|
||||
const result = generateActivities({
|
||||
dept_sales: true, // triggers sales-kundenverwaltung
|
||||
sys_crm: true, // also triggers sales-kundenverwaltung
|
||||
})
|
||||
|
||||
const salesKunden = result.generatedActivities.filter((a) =>
|
||||
a.name.toLowerCase().includes('kundenverwaltung')
|
||||
)
|
||||
// Should be deduplicated (Set-based triggeredIds)
|
||||
expect(salesKunden.length).toBe(1)
|
||||
})
|
||||
|
||||
it('empty department category selections produce valid but empty mappings', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', [])]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Loeschfristen Baseline-Katalog
|
||||
*
|
||||
* 18 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
|
||||
* 25 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
|
||||
* Datenobjekte in deutschen Unternehmen. Basierend auf AO, HGB,
|
||||
* UStG, BGB, ArbZG, AGG, BDSG und BSIG.
|
||||
* UStG, BGB, ArbZG, AGG, BDSG, BSIG und ArbMedVV.
|
||||
*
|
||||
* Werden genutzt, um neue Loeschfrist-Policies schnell aus
|
||||
* bewaehrten Vorlagen zu erstellen.
|
||||
@@ -48,7 +48,7 @@ export interface BaselineTemplate {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BASELINE TEMPLATES (18 Vorlagen)
|
||||
// BASELINE TEMPLATES (25 Vorlagen)
|
||||
// =============================================================================
|
||||
|
||||
export const BASELINE_TEMPLATES: BaselineTemplate[] = [
|
||||
@@ -519,6 +519,188 @@ export const BASELINE_TEMPLATES: BaselineTemplate[] = [
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['datenschutz', 'consent'],
|
||||
},
|
||||
|
||||
// ==================== 19. E-Mail-Archivierung ====================
|
||||
{
|
||||
templateId: 'email-archivierung',
|
||||
dataObjectName: 'E-Mail-Archivierung',
|
||||
description:
|
||||
'Archivierte geschaeftliche E-Mails inkl. Anhaenge, die als Handelsbriefe oder steuerrelevante Korrespondenz einzustufen sind.',
|
||||
affectedGroups: ['Mitarbeiter', 'Kunden', 'Lieferanten'],
|
||||
dataCategories: ['E-Mail-Korrespondenz', 'Anhaenge', 'Metadaten'],
|
||||
primaryPurpose:
|
||||
'Erfuellung der handelsrechtlichen Aufbewahrungspflicht fuer geschaeftliche Korrespondenz, die als Handelsbrief einzuordnen ist.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'HGB_257',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrungspflicht gemaess 257 HGB fuer empfangene und versandte Handelsbriefe (6 Jahre) bzw. buchhalterisch relevante E-Mails (10 Jahre).',
|
||||
retentionDuration: 6,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '6 Jahre nach Versand/Empfang der E-Mail',
|
||||
startEvent: 'Versand- bzw. Empfangsdatum der E-Mail',
|
||||
deletionMethod: 'AUTO_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Automatische Loeschung durch das E-Mail-Archivierungssystem nach Ablauf der konfigurierten Aufbewahrungsfrist. Vor Loeschung wird geprueft, ob die E-Mail in laufenden Verfahren benoetigt wird.',
|
||||
responsibleRole: 'IT-Abteilung',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['kommunikation', 'hgb'],
|
||||
},
|
||||
|
||||
// ==================== 20. Zutrittsprotokolle ====================
|
||||
{
|
||||
templateId: 'zutrittsprotokolle',
|
||||
dataObjectName: 'Zutrittsprotokolle',
|
||||
description:
|
||||
'Protokolle des Zutrittskontrollsystems inkl. Zeitstempel, Kartennummer, Zutrittsort und Zugangsentscheidung (gewaehrt/verweigert).',
|
||||
affectedGroups: ['Mitarbeiter', 'Besucher'],
|
||||
dataCategories: ['Zutrittsdaten', 'Zeitstempel', 'Kartennummern', 'Standortdaten'],
|
||||
primaryPurpose:
|
||||
'Sicherstellung der physischen Sicherheit, Nachvollziehbarkeit von Zutritten und Unterstuetzung bei der Aufklaerung von Sicherheitsvorfaellen.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'BSIG',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung gemaess BSI-Grundschutz-Empfehlung fuer Zutrittsprotokolle zur Analyse von Sicherheitsvorfaellen (90 Tage).',
|
||||
retentionDuration: 90,
|
||||
retentionUnit: 'DAYS',
|
||||
retentionDescription: '90 Tage nach Zeitpunkt des Zutritts',
|
||||
startEvent: 'Zeitpunkt des protokollierten Zutrittsereignisses',
|
||||
deletionMethod: 'AUTO_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Automatische Rotation und Loeschung der Zutrittsprotokolle durch das Zutrittskontrollsystem nach Ablauf der 90-Tage-Frist.',
|
||||
responsibleRole: 'Facility Management',
|
||||
reviewInterval: 'QUARTERLY',
|
||||
tags: ['sicherheit', 'zutritt'],
|
||||
},
|
||||
|
||||
// ==================== 21. Schulungsnachweise ====================
|
||||
{
|
||||
templateId: 'schulungsnachweise',
|
||||
dataObjectName: 'Schulungsnachweise',
|
||||
description:
|
||||
'Teilnahmebestaetigungen, Zertifikate und Protokolle von Mitarbeiterschulungen (Datenschutz, Arbeitssicherheit, Compliance).',
|
||||
affectedGroups: ['Mitarbeiter'],
|
||||
dataCategories: ['Schulungsdaten', 'Zertifikate', 'Teilnahmelisten'],
|
||||
primaryPurpose:
|
||||
'Nachweis der Durchfuehrung gesetzlich vorgeschriebener Schulungen und Dokumentation der Mitarbeiterqualifikation.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'CUSTOM',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung fuer 3 Jahre nach Ende des Beschaeftigungsverhaeltnisses als Nachweis der ordnungsgemaessen Schulungsdurchfuehrung.',
|
||||
retentionDuration: 3,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '3 Jahre nach Ende des Beschaeftigungsverhaeltnisses',
|
||||
startEvent: 'Ende des Beschaeftigungsverhaeltnisses des geschulten Mitarbeiters',
|
||||
deletionMethod: 'MANUAL_REVIEW_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Manuelle Pruefung durch die HR-Abteilung vor Loeschung, da Schulungsnachweise als Compliance-Nachweis in Audits relevant sein koennen.',
|
||||
responsibleRole: 'HR-Abteilung',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['hr', 'schulung'],
|
||||
},
|
||||
|
||||
// ==================== 22. Betriebsarzt-Dokumentation ====================
|
||||
{
|
||||
templateId: 'betriebsarzt-doku',
|
||||
dataObjectName: 'Betriebsarzt-Dokumentation',
|
||||
description:
|
||||
'Ergebnisse arbeitsmedizinischer Vorsorgeuntersuchungen, Eignungsuntersuchungen und arbeitsmedizinische Empfehlungen.',
|
||||
affectedGroups: ['Mitarbeiter'],
|
||||
dataCategories: ['Gesundheitsdaten', 'Vorsorgeuntersuchungen', 'Eignungsbefunde'],
|
||||
primaryPurpose:
|
||||
'Erfuellung der Dokumentationspflicht fuer arbeitsmedizinische Vorsorge gemaess ArbMedVV und Nachweisfuehrung gegenueber Berufsgenossenschaften.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'CUSTOM',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrungspflicht gemaess ArbMedVV (Verordnung zur arbeitsmedizinischen Vorsorge) und Berufsgenossenschaftliche Grundsaetze: bis zu 40 Jahre bei Exposition gegenueber krebserzeugenden Gefahrstoffen.',
|
||||
retentionDuration: 40,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '40 Jahre nach letzter Exposition (bei Gefahrstoffen), sonst 10 Jahre nach Ende der Taetigkeit',
|
||||
startEvent: 'Ende der expositionsrelevanten Taetigkeit bzw. Ende des Beschaeftigungsverhaeltnisses',
|
||||
deletionMethod: 'PHYSICAL_DESTROY',
|
||||
deletionMethodDetail:
|
||||
'Physische Vernichtung der Papierunterlagen durch zertifizierten Aktenvernichtungsdienstleister (DIN 66399, Sicherheitsstufe P-5). Digitale Daten werden kryptographisch geloescht.',
|
||||
responsibleRole: 'Betriebsarzt / Arbeitsmedizinischer Dienst',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['hr', 'gesundheit'],
|
||||
},
|
||||
|
||||
// ==================== 23. Kundenreklamationen ====================
|
||||
{
|
||||
templateId: 'kundenreklamationen',
|
||||
dataObjectName: 'Kundenreklamationen',
|
||||
description:
|
||||
'Reklamationsvorgaenge inkl. Beschwerdeinhalt, Kommunikationsverlauf, Massnahmen und Ergebnis der Reklamationsbearbeitung.',
|
||||
affectedGroups: ['Kunden'],
|
||||
dataCategories: ['Reklamationsdaten', 'Kommunikation', 'Massnahmenprotokolle'],
|
||||
primaryPurpose:
|
||||
'Dokumentation und Bearbeitung von Kundenreklamationen, Qualitaetssicherung und Absicherung gegen Gewaehrleistungsansprueche.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'BGB_195',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB (3 Jahre) zur Absicherung gegen Gewaehrleistungs- und Schadensersatzansprueche.',
|
||||
retentionDuration: 3,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '3 Jahre nach Abschluss des Reklamationsvorgangs',
|
||||
startEvent: 'Abschluss des Reklamationsvorgangs (letzte Massnahme)',
|
||||
deletionMethod: 'ANONYMIZATION',
|
||||
deletionMethodDetail:
|
||||
'Anonymisierung der personenbezogenen Daten nach Ablauf der Frist. Anonymisierte Reklamationsstatistiken bleiben fuer die Qualitaetssicherung erhalten.',
|
||||
responsibleRole: 'Qualitaetsmanagement',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['kunden', 'qualitaet'],
|
||||
},
|
||||
|
||||
// ==================== 24. Lieferantenbewertungen ====================
|
||||
{
|
||||
templateId: 'lieferantenbewertungen',
|
||||
dataObjectName: 'Lieferantenbewertungen',
|
||||
description:
|
||||
'Bewertungen und Auditergebnisse von Lieferanten und Auftragsverarbeitern inkl. Qualitaets-, Compliance- und Datenschutz-Bewertungen.',
|
||||
affectedGroups: ['Lieferanten', 'Auftragsverarbeiter'],
|
||||
dataCategories: ['Bewertungsdaten', 'Auditberichte', 'Vertragsdaten'],
|
||||
primaryPurpose:
|
||||
'Dokumentation der Sorgfaltspflicht bei der Auswahl und Ueberwachung von Auftragsverarbeitern gemaess Art. 28 DSGVO und Qualitaetssicherung in der Lieferkette.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'HGB_257',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung gemaess 257 HGB als handelsrelevante Unterlagen sowie zur Nachweisfuehrung der Sorgfaltspflicht bei der Auftragsverarbeitung.',
|
||||
retentionDuration: 6,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '6 Jahre nach Ende der Geschaeftsbeziehung',
|
||||
startEvent: 'Ende der Geschaeftsbeziehung mit dem Lieferanten/Auftragsverarbeiter',
|
||||
deletionMethod: 'MANUAL_REVIEW_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Manuelle Pruefung durch den Einkauf/Compliance-Abteilung vor Loeschung, um sicherzustellen, dass keine Nachweispflichten aus laufenden Vertraegen oder Audits bestehen.',
|
||||
responsibleRole: 'Einkauf / Compliance',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['lieferanten', 'einkauf'],
|
||||
},
|
||||
|
||||
// ==================== 25. Social-Media-Marketingdaten ====================
|
||||
{
|
||||
templateId: 'social-media-daten',
|
||||
dataObjectName: 'Social-Media-Marketingdaten',
|
||||
description:
|
||||
'Personenbezogene Daten aus Social-Media-Kampagnen inkl. Nutzerinteraktionen, Custom Audiences, Retargeting-Listen und Kampagnen-Analytics.',
|
||||
affectedGroups: ['Kunden', 'Interessenten', 'Website-Besucher'],
|
||||
dataCategories: ['Interaktionsdaten', 'Zielgruppendaten', 'Tracking-Daten', 'Profilmerkmale'],
|
||||
primaryPurpose:
|
||||
'Durchfuehrung zielgerichteter Marketing-Kampagnen auf Social-Media-Plattformen und Analyse der Kampagneneffektivitaet.',
|
||||
deletionTrigger: 'PURPOSE_END',
|
||||
retentionDriver: null,
|
||||
retentionDriverDetail:
|
||||
'Keine gesetzliche Aufbewahrungspflicht. Daten werden bis zum Widerruf der Einwilligung bzw. bis zum Ende der Kampagne gespeichert (Art. 6 Abs. 1 lit. a DSGVO).',
|
||||
retentionDuration: null,
|
||||
retentionUnit: null,
|
||||
retentionDescription: 'Bis zum Widerruf der Einwilligung oder Ende des Kampagnenzwecks',
|
||||
startEvent: 'Widerruf der Einwilligung oder Ende der Marketing-Kampagne',
|
||||
deletionMethod: 'AUTO_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Automatische Loeschung der personenbezogenen Daten in den Social-Media-Werbekonten und internen Systemen nach Zweckwegfall. Custom Audiences werden bei Plattformanbietern geloescht.',
|
||||
responsibleRole: 'Marketing',
|
||||
reviewInterval: 'SEMI_ANNUAL',
|
||||
tags: ['marketing', 'social'],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import {
|
||||
LoeschfristPolicy,
|
||||
PolicyStatus,
|
||||
RetentionDriverType,
|
||||
isPolicyOverdue,
|
||||
getActiveLegalHolds,
|
||||
RETENTION_DRIVER_META,
|
||||
} from './loeschfristen-types'
|
||||
|
||||
// =============================================================================
|
||||
@@ -22,6 +24,10 @@ export type ComplianceIssueType =
|
||||
| 'LEGAL_HOLD_CONFLICT'
|
||||
| 'STALE_DRAFT'
|
||||
| 'UNCOVERED_VVT_CATEGORY'
|
||||
| 'MISSING_DELETION_METHOD'
|
||||
| 'MISSING_STORAGE_LOCATIONS'
|
||||
| 'EXCESSIVE_RETENTION'
|
||||
| 'MISSING_DATA_CATEGORIES'
|
||||
|
||||
export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
||||
|
||||
@@ -219,6 +225,108 @@ function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: MISSING_DELETION_METHOD (MEDIUM)
|
||||
* Active policy without a deletion method detail description.
|
||||
*/
|
||||
function checkMissingDeletionMethod(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (policy.status === 'ACTIVE' && !policy.deletionMethodDetail.trim()) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'MISSING_DELETION_METHOD',
|
||||
'MEDIUM',
|
||||
'Keine Loeschmethode beschrieben',
|
||||
`Die aktive Policy "${policy.dataObjectName}" hat keine detaillierte Beschreibung der Loeschmethode. Fuer ein auditfaehiges Loeschkonzept muss dokumentiert sein, wie die Loeschung technisch durchgefuehrt wird.`,
|
||||
'Ergaenzen Sie eine detaillierte Beschreibung der Loeschmethode (z.B. automatisches Loeschen durch Datenbank-Job, manuelle Pruefung durch Fachabteilung, kryptographische Loeschung).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: MISSING_STORAGE_LOCATIONS (MEDIUM)
|
||||
* Active policy without any documented storage locations.
|
||||
*/
|
||||
function checkMissingStorageLocations(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (policy.status === 'ACTIVE' && policy.storageLocations.length === 0) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'MISSING_STORAGE_LOCATIONS',
|
||||
'MEDIUM',
|
||||
'Keine Speicherorte dokumentiert',
|
||||
`Die aktive Policy "${policy.dataObjectName}" hat keine Speicherorte hinterlegt. Ohne Speicherort-Dokumentation ist unklar, wo die Daten gespeichert sind und wo die Loeschung durchgefuehrt werden muss.`,
|
||||
'Dokumentieren Sie mindestens einen Speicherort (z.B. Datenbank, Cloud-Speicher, E-Mail-System, Papierarchiv).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: EXCESSIVE_RETENTION (HIGH)
|
||||
* Retention duration exceeds 2x the legal default for the driver.
|
||||
*/
|
||||
function checkExcessiveRetention(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (
|
||||
policy.retentionDriver &&
|
||||
policy.retentionDriver !== 'CUSTOM' &&
|
||||
policy.retentionDuration !== null &&
|
||||
policy.retentionUnit !== null
|
||||
) {
|
||||
const meta = RETENTION_DRIVER_META[policy.retentionDriver]
|
||||
if (meta.defaultDuration !== null && meta.defaultUnit !== null) {
|
||||
// Normalize both to days for comparison
|
||||
const policyDays = toDays(policy.retentionDuration, policy.retentionUnit)
|
||||
const legalDays = toDays(meta.defaultDuration, meta.defaultUnit)
|
||||
|
||||
if (legalDays > 0 && policyDays > legalDays * 2) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'EXCESSIVE_RETENTION',
|
||||
'HIGH',
|
||||
'Ueberschreitung der gesetzlichen Aufbewahrungsfrist',
|
||||
`Die Policy "${policy.dataObjectName}" hat eine Aufbewahrungsdauer von ${policy.retentionDuration} ${policy.retentionUnit === 'YEARS' ? 'Jahren' : policy.retentionUnit === 'MONTHS' ? 'Monaten' : 'Tagen'}, die mehr als das Doppelte der gesetzlichen Frist (${meta.defaultDuration} ${meta.defaultUnit === 'YEARS' ? 'Jahre' : meta.defaultUnit === 'MONTHS' ? 'Monate' : 'Tage'} nach ${meta.statute}) betraegt. Ueberlange Speicherung widerspricht dem Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e DSGVO).`,
|
||||
'Pruefen Sie, ob die verlaengerte Aufbewahrungsdauer gerechtfertigt ist. Falls nicht, reduzieren Sie sie auf die gesetzliche Mindestfrist.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: MISSING_DATA_CATEGORIES (LOW)
|
||||
* Non-draft policy without any data categories assigned.
|
||||
*/
|
||||
function checkMissingDataCategories(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (policy.status !== 'DRAFT' && policy.dataCategories.length === 0) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'MISSING_DATA_CATEGORIES',
|
||||
'LOW',
|
||||
'Keine Datenkategorien zugeordnet',
|
||||
`Die Policy "${policy.dataObjectName}" (Status: ${policy.status}) hat keine Datenkategorien zugeordnet. Ohne Datenkategorien ist unklar, welche personenbezogenen Daten von dieser Loeschregel betroffen sind.`,
|
||||
'Ordnen Sie mindestens eine Datenkategorie zu (z.B. Stammdaten, Kontaktdaten, Finanzdaten, Gesundheitsdaten).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: convert retention duration to days for comparison.
|
||||
*/
|
||||
function toDays(duration: number, unit: string): number {
|
||||
switch (unit) {
|
||||
case 'DAYS': return duration
|
||||
case 'MONTHS': return duration * 30
|
||||
case 'YEARS': return duration * 365
|
||||
default: return duration
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
@@ -248,6 +356,10 @@ export function runComplianceCheck(
|
||||
checkNoResponsible(policy),
|
||||
checkLegalHoldConflict(policy),
|
||||
checkStaleDraft(policy),
|
||||
checkMissingDeletionMethod(policy),
|
||||
checkMissingStorageLocations(policy),
|
||||
checkExcessiveRetention(policy),
|
||||
checkMissingDataCategories(policy),
|
||||
]
|
||||
|
||||
for (const issue of checks) {
|
||||
|
||||
879
admin-compliance/lib/sdk/loeschfristen-document.ts
Normal file
879
admin-compliance/lib/sdk/loeschfristen-document.ts
Normal file
@@ -0,0 +1,879 @@
|
||||
// =============================================================================
|
||||
// Loeschfristen Module - Loeschkonzept Document Generator
|
||||
// Generates a printable, audit-ready HTML document according to DSGVO Art. 5/17/30
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
LoeschfristPolicy,
|
||||
RetentionDriverType,
|
||||
} from './loeschfristen-types'
|
||||
|
||||
import {
|
||||
RETENTION_DRIVER_META,
|
||||
DELETION_METHOD_LABELS,
|
||||
STATUS_LABELS,
|
||||
TRIGGER_LABELS,
|
||||
REVIEW_INTERVAL_LABELS,
|
||||
formatRetentionDuration,
|
||||
getEffectiveDeletionTrigger,
|
||||
getActiveLegalHolds,
|
||||
} from './loeschfristen-types'
|
||||
|
||||
import type { ComplianceCheckResult, ComplianceIssueSeverity } from './loeschfristen-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface LoeschkonzeptOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
locations: string[]
|
||||
employeeCount: string
|
||||
loeschkonzeptVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface LoeschkonzeptRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultLoeschkonzeptOrgHeader(): LoeschkonzeptOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
locations: [],
|
||||
employeeCount: '',
|
||||
loeschkonzeptVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEVERITY LABELS (for Compliance Status section)
|
||||
// =============================================================================
|
||||
|
||||
const SEVERITY_LABELS_DE: Record<ComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<ComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildLoeschkonzeptHtml(
|
||||
policies: LoeschfristPolicy[],
|
||||
orgHeader: LoeschkonzeptOrgHeader,
|
||||
vvtActivities: Array<{ id: string; vvt_id?: string; vvtId?: string; name?: string; activity_name?: string }>,
|
||||
complianceResult: ComplianceCheckResult | null,
|
||||
revisions: LoeschkonzeptRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Collect unique storage locations across all policies
|
||||
const allStorageLocations = new Set<string>()
|
||||
for (const p of activePolicies) {
|
||||
for (const loc of p.storageLocations) {
|
||||
allStorageLocations.add(loc.name || loc.type)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unique responsible roles
|
||||
const roleMap = new Map<string, string[]>()
|
||||
for (const p of activePolicies) {
|
||||
const role = p.responsibleRole || p.responsiblePerson || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
roleMap.get(role)!.push(p.dataObjectName || p.policyId)
|
||||
}
|
||||
|
||||
// Collect active legal holds
|
||||
const allActiveLegalHolds: Array<{ policy: string; hold: LoeschfristPolicy['legalHolds'][0] }> = []
|
||||
for (const p of activePolicies) {
|
||||
for (const h of getActiveLegalHolds(p)) {
|
||||
allActiveLegalHolds.push({ policy: p.dataObjectName || p.policyId, hold: h })
|
||||
}
|
||||
}
|
||||
|
||||
// Build VVT cross-reference data
|
||||
const vvtRefs: Array<{ policyName: string; policyId: string; vvtId: string; vvtName: string }> = []
|
||||
for (const p of activePolicies) {
|
||||
for (const linkedId of p.linkedVVTActivityIds) {
|
||||
const activity = vvtActivities.find(a => a.id === linkedId)
|
||||
if (activity) {
|
||||
vvtRefs.push({
|
||||
policyName: p.dataObjectName || p.policyId,
|
||||
policyId: p.policyId,
|
||||
vvtId: activity.vvt_id || activity.vvtId || linkedId.substring(0, 8),
|
||||
vvtName: activity.activity_name || activity.name || 'Unbenannte Verarbeitungstaetigkeit',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build vendor cross-reference data
|
||||
const vendorRefs: Array<{ policyName: string; policyId: string; vendorId: string; duration: string }> = []
|
||||
for (const p of activePolicies) {
|
||||
if (p.linkedVendorIds && p.linkedVendorIds.length > 0) {
|
||||
for (const vendorId of p.linkedVendorIds) {
|
||||
vendorRefs.push({
|
||||
policyName: p.dataObjectName || p.policyId,
|
||||
policyId: p.policyId,
|
||||
vendorId,
|
||||
duration: formatRetentionDuration(p.retentionDuration, p.retentionUnit),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Loeschkonzept — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>Loeschkonzept</h1>
|
||||
<div class="subtitle">gemaess Art. 5 Abs. 1 lit. e, Art. 17, Art. 30 DSGVO</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">Datenschutzbeauftragter:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
|
||||
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.loeschkonzeptVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Grundprinzipien der Datenspeicherung',
|
||||
'Loeschregeln-Uebersicht',
|
||||
'Detaillierte Loeschregeln',
|
||||
'VVT-Verknuepfung',
|
||||
'Auftragsverarbeiter mit Loeschpflichten',
|
||||
'Legal Hold Verfahren',
|
||||
'Verantwortlichkeiten',
|
||||
'Pruef- und Revisionszyklus',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Loeschkonzept definiert die systematischen Regeln und Verfahren fuer die Loeschung
|
||||
personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Es dient der Umsetzung
|
||||
folgender DSGVO-Anforderungen:</p>
|
||||
<table>
|
||||
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
|
||||
<tr><td><strong>Art. 5 Abs. 1 lit. e DSGVO</strong></td><td>Grundsatz der Speicherbegrenzung — personenbezogene Daten duerfen nur so lange gespeichert werden, wie es fuer die Zwecke der Verarbeitung erforderlich ist.</td></tr>
|
||||
<tr><td><strong>Art. 17 DSGVO</strong></td><td>Recht auf Loeschung („Recht auf Vergessenwerden“) — Betroffene haben das Recht, die Loeschung ihrer Daten zu verlangen.</td></tr>
|
||||
<tr><td><strong>Art. 30 DSGVO</strong></td><td>Verzeichnis von Verarbeitungstaetigkeiten — vorgesehene Fristen fuer die Loeschung der verschiedenen Datenkategorien muessen dokumentiert werden.</td></tr>
|
||||
</table>
|
||||
<p>Das Loeschkonzept ist fester Bestandteil des Datenschutz-Managementsystems und wird
|
||||
regelmaessig ueberprueft und aktualisiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
const storageListHtml = allStorageLocations.size > 0
|
||||
? Array.from(allStorageLocations).map(s => `<li>${escHtml(s)}</li>`).join('')
|
||||
: '<li><em>Keine Speicherorte dokumentiert</em></li>'
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Loeschkonzept gilt fuer alle personenbezogenen Daten, die von <strong>${escHtml(orgName)}</strong>
|
||||
verarbeitet werden. Es umfasst <strong>${activePolicies.length}</strong> Loeschregeln fuer folgende Systeme und Speicherorte:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
${storageListHtml}
|
||||
</ul>
|
||||
<p>Saemtliche Verarbeitungstaetigkeiten, die im Verzeichnis von Verarbeitungstaetigkeiten (VVT)
|
||||
erfasst sind, werden durch dieses Loeschkonzept abgedeckt.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Grundprinzipien
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Grundprinzipien der Datenspeicherung</div>
|
||||
<div class="section-body">
|
||||
<div class="principle"><strong>Speicherbegrenzung:</strong> Personenbezogene Daten werden nur so lange gespeichert, wie es fuer den jeweiligen Verarbeitungszweck erforderlich ist (Art. 5 Abs. 1 lit. e DSGVO).</div>
|
||||
<div class="principle"><strong>3-Level-Loeschlogik:</strong> Die Loeschung folgt einer dreistufigen Priorisierung: (1) Zweckende, (2) gesetzliche Aufbewahrungspflichten, (3) Legal Hold — jeweils mit der laengsten Frist als massgeblich.</div>
|
||||
<div class="principle"><strong>Dokumentationspflicht:</strong> Jede Loeschregel ist dokumentiert mit Rechtsgrundlage, Frist, Loeschmethode und Verantwortlichkeit.</div>
|
||||
<div class="principle"><strong>Regelmaessige Ueberpruefung:</strong> Alle Loeschregeln werden im definierten Intervall ueberprueft und bei Bedarf angepasst.</div>
|
||||
<div class="principle"><strong>Datenschutz durch Technikgestaltung:</strong> Loeschmechanismen werden moeglichst automatisiert, um menschliche Fehler zu minimieren (Art. 25 DSGVO).</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Loeschregeln-Uebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">4. Loeschregeln-Uebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${activePolicies.length} aktiven Loeschregeln:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>LF-Nr.</th>
|
||||
<th>Datenobjekt</th>
|
||||
<th>Loeschtrigger</th>
|
||||
<th>Aufbewahrungsfrist</th>
|
||||
<th>Loeschmethode</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
`
|
||||
for (const p of activePolicies) {
|
||||
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
|
||||
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
|
||||
const method = DELETION_METHOD_LABELS[p.deletionMethod]
|
||||
const statusLabel = STATUS_LABELS[p.status]
|
||||
const statusClass = p.status === 'ACTIVE' ? 'badge-active' : p.status === 'REVIEW_NEEDED' ? 'badge-review' : 'badge-draft'
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(p.policyId)}</td>
|
||||
<td>${escHtml(p.dataObjectName)}</td>
|
||||
<td>${escHtml(trigger)}</td>
|
||||
<td>${escHtml(duration)}</td>
|
||||
<td>${escHtml(method)}</td>
|
||||
<td><span class="badge ${statusClass}">${escHtml(statusLabel)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Detaillierte Loeschregeln
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">5. Detaillierte Loeschregeln</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
for (const p of activePolicies) {
|
||||
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
|
||||
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
|
||||
const method = DELETION_METHOD_LABELS[p.deletionMethod]
|
||||
const statusLabel = STATUS_LABELS[p.status]
|
||||
const driverLabel = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.label || p.retentionDriver : '-'
|
||||
const driverStatute = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.statute || '' : ''
|
||||
const locations = p.storageLocations.map(l => l.name || l.type).join(', ') || '-'
|
||||
const responsible = [p.responsiblePerson, p.responsibleRole].filter(s => s.trim()).join(' / ') || '-'
|
||||
const activeHolds = getActiveLegalHolds(p)
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(p.policyId)} — ${escHtml(p.dataObjectName)}</span>
|
||||
<span class="badge ${p.status === 'ACTIVE' ? 'badge-active' : 'badge-draft'}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(p.description || '-')}</td></tr>
|
||||
<tr><th>Betroffenengruppen</th><td>${escHtml(p.affectedGroups.join(', ') || '-')}</td></tr>
|
||||
<tr><th>Datenkategorien</th><td>${escHtml(p.dataCategories.join(', ') || '-')}</td></tr>
|
||||
<tr><th>Verarbeitungszweck</th><td>${escHtml(p.primaryPurpose || '-')}</td></tr>
|
||||
<tr><th>Loeschtrigger</th><td>${escHtml(trigger)}</td></tr>
|
||||
<tr><th>Aufbewahrungstreiber</th><td>${escHtml(driverLabel)}${driverStatute ? ` (${escHtml(driverStatute)})` : ''}</td></tr>
|
||||
<tr><th>Aufbewahrungsfrist</th><td>${escHtml(duration)}</td></tr>
|
||||
<tr><th>Startereignis</th><td>${escHtml(p.startEvent || '-')}</td></tr>
|
||||
<tr><th>Loeschmethode</th><td>${escHtml(method)}</td></tr>
|
||||
<tr><th>Loeschmethode (Detail)</th><td>${escHtml(p.deletionMethodDetail || '-')}</td></tr>
|
||||
<tr><th>Speicherorte</th><td>${escHtml(locations)}</td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
|
||||
<tr><th>Pruefintervall</th><td>${escHtml(REVIEW_INTERVAL_LABELS[p.reviewInterval] || p.reviewInterval)}</td></tr>
|
||||
${activeHolds.length > 0 ? `<tr><th>Aktive Legal Holds</th><td>${activeHolds.map(h => `${escHtml(h.reason)} (seit ${formatDateDE(h.startDate)})`).join('<br/>')}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: VVT-Verknuepfung
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">6. VVT-Verknuepfung</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die Verknuepfung zwischen Loeschregeln und Verarbeitungstaetigkeiten
|
||||
im VVT (Art. 30 DSGVO):</p>
|
||||
`
|
||||
if (vvtRefs.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>VVT-Nr.</th><th>Verarbeitungstaetigkeit</th></tr>
|
||||
`
|
||||
for (const ref of vvtRefs) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(ref.policyName)}</td>
|
||||
<td>${escHtml(ref.policyId)}</td>
|
||||
<td>${escHtml(ref.vvtId)}</td>
|
||||
<td>${escHtml(ref.vvtName)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Noch keine VVT-Verknuepfungen dokumentiert. Verknuepfen Sie Ihre Loeschregeln
|
||||
mit den entsprechenden Verarbeitungstaetigkeiten im Editor-Tab.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Auftragsverarbeiter mit Loeschpflichten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">7. Auftragsverarbeiter mit Loeschpflichten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt Loeschregeln, die mit Auftragsverarbeitern verknuepft sind.
|
||||
Diese Verknuepfungen stellen sicher, dass auch bei extern verarbeiteten Daten die Loeschpflichten
|
||||
eingehalten werden (Art. 28 DSGVO).</p>
|
||||
`
|
||||
if (vendorRefs.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>Auftragsverarbeiter (ID)</th><th>Aufbewahrungsfrist</th></tr>
|
||||
`
|
||||
for (const ref of vendorRefs) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(ref.policyName)}</td>
|
||||
<td>${escHtml(ref.policyId)}</td>
|
||||
<td>${escHtml(ref.vendorId)}</td>
|
||||
<td>${escHtml(ref.duration)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Noch keine Auftragsverarbeiter mit Loeschregeln verknuepft. Verknuepfen Sie Ihre
|
||||
Loeschregeln mit den entsprechenden Auftragsverarbeitern im Editor-Tab.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Legal Hold Verfahren
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Legal Hold Verfahren</div>
|
||||
<div class="section-body">
|
||||
<p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere
|
||||
Loeschung aus. Betroffene Daten duerfen trotz abgelaufener Loeschfrist nicht geloescht werden,
|
||||
bis der Legal Hold aufgehoben wird.</p>
|
||||
<p><strong>Verfahrensschritte:</strong></p>
|
||||
<ol style="margin: 8px 0 8px 24px;">
|
||||
<li>Rechtsabteilung/DSB identifiziert betroffene Datenkategorien</li>
|
||||
<li>Legal Hold wird im System aktiviert (Status: Aktiv)</li>
|
||||
<li>Automatische Loeschung wird fuer betroffene Policies ausgesetzt</li>
|
||||
<li>Regelmaessige Pruefung, ob der Legal Hold noch erforderlich ist</li>
|
||||
<li>Nach Aufhebung: Regulaere Loeschfristen greifen wieder</li>
|
||||
</ol>
|
||||
`
|
||||
if (allActiveLegalHolds.length > 0) {
|
||||
html += ` <p><strong>Aktuell aktive Legal Holds (${allActiveLegalHolds.length}):</strong></p>
|
||||
<table>
|
||||
<tr><th>Datenobjekt</th><th>Grund</th><th>Rechtsgrundlage</th><th>Seit</th><th>Voraussichtlich bis</th></tr>
|
||||
`
|
||||
for (const { policy, hold } of allActiveLegalHolds) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(policy)}</td>
|
||||
<td>${escHtml(hold.reason)}</td>
|
||||
<td>${escHtml(hold.legalBasis)}</td>
|
||||
<td>${formatDateDE(hold.startDate)}</td>
|
||||
<td>${hold.expectedEndDate ? formatDateDE(hold.expectedEndDate) : 'Unbefristet'}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Derzeit sind keine aktiven Legal Holds vorhanden.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">9. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte
|
||||
die Loeschverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Rolle / Verantwortlich</th><th>Datenobjekte</th><th>Anzahl</th></tr>
|
||||
`
|
||||
for (const [role, objects] of roleMap.entries()) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${objects.map(o => escHtml(o)).join(', ')}</td>
|
||||
<td>${objects.length}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Pruef- und Revisionszyklus
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">10. Pruef- und Revisionszyklus</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
|
||||
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
|
||||
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
|
||||
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.loeschkonzeptVersion)}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 8px;">Bei jeder Pruefung wird das Loeschkonzept auf folgende Punkte ueberprueft:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendigkeit aller Loeschregeln (neue Verarbeitungen erfasst?)</li>
|
||||
<li>Aktualitaet der gesetzlichen Aufbewahrungsfristen</li>
|
||||
<li>Wirksamkeit der technischen Loeschmechanismen</li>
|
||||
<li>Einhaltung der definierten Loeschfristen</li>
|
||||
<li>Angemessenheit der Verantwortlichkeiten</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">11. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Gepruefte Policies</td><td>${complianceResult.stats.total}</td></tr>
|
||||
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
|
||||
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
|
||||
`
|
||||
const severityOrder: ComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const count = complianceResult.stats.bySeverity[sev]
|
||||
if (count === 0) continue
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${count}</td>
|
||||
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Policies sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 12: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">12. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.loeschkonzeptVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
|
||||
<td>Erstversion des Loeschkonzepts</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>Loeschkonzept — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.loeschkonzeptVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// =============================================================================
|
||||
// Loeschfristen Module - Profiling Wizard
|
||||
// 4-Step Profiling (15 Fragen) zur Generierung von Baseline-Loeschrichtlinien
|
||||
// 4-Step Profiling (16 Fragen) zur Generierung von Baseline-Loeschrichtlinien
|
||||
// =============================================================================
|
||||
|
||||
import type { LoeschfristPolicy, StorageLocation } from './loeschfristen-types'
|
||||
@@ -42,7 +42,7 @@ export interface ProfilingResult {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROFILING STEPS (4 Steps, 15 Questions)
|
||||
// PROFILING STEPS (4 Steps, 16 Questions)
|
||||
// =============================================================================
|
||||
|
||||
export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
@@ -163,7 +163,7 @@ export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Step 3: Systeme (3 Fragen)
|
||||
// Step 3: Systeme (4 Fragen)
|
||||
// =========================================================================
|
||||
{
|
||||
id: 'systems',
|
||||
@@ -194,6 +194,14 @@ export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-zutritt',
|
||||
step: 'systems',
|
||||
question: 'Nutzen Sie ein Zutrittskontrollsystem?',
|
||||
helpText: 'Zutrittskontrollsysteme erzeugen Protokolle, die personenbezogene Daten enthalten und einer Loeschfrist unterliegen.',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -340,6 +348,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('zeiterfassung')
|
||||
matchedTemplateIds.add('bewerbungsunterlagen')
|
||||
matchedTemplateIds.add('krankmeldungen')
|
||||
matchedTemplateIds.add('schulungsnachweise')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -358,6 +367,8 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('vertraege')
|
||||
matchedTemplateIds.add('geschaeftsbriefe')
|
||||
matchedTemplateIds.add('kundenstammdaten')
|
||||
matchedTemplateIds.add('kundenreklamationen')
|
||||
matchedTemplateIds.add('lieferantenbewertungen')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -367,6 +378,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('newsletter-einwilligungen')
|
||||
matchedTemplateIds.add('crm-kontakthistorie')
|
||||
matchedTemplateIds.add('cookie-consent-logs')
|
||||
matchedTemplateIds.add('social-media-daten')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -384,6 +396,20 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('cookie-consent-logs')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cloud (sys-cloud = true) → E-Mail-Archivierung
|
||||
// -------------------------------------------------------------------------
|
||||
if (getBool('sys-cloud')) {
|
||||
matchedTemplateIds.add('email-archivierung')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zutritt (sys-zutritt = true)
|
||||
// -------------------------------------------------------------------------
|
||||
if (getBool('sys-zutritt')) {
|
||||
matchedTemplateIds.add('zutrittsprotokolle')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ERP/CRM (sys-erp = true)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -405,6 +431,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
if (getBool('special-gesundheit')) {
|
||||
// Ensure krankmeldungen is included even without full HR data
|
||||
matchedTemplateIds.add('krankmeldungen')
|
||||
matchedTemplateIds.add('betriebsarzt-doku')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface LoeschfristPolicy {
|
||||
responsiblePerson: string
|
||||
releaseProcess: string
|
||||
linkedVVTActivityIds: string[]
|
||||
linkedVendorIds: string[]
|
||||
// Status & Review
|
||||
status: PolicyStatus
|
||||
lastReviewDate: string
|
||||
@@ -272,6 +273,7 @@ export function createEmptyPolicy(): LoeschfristPolicy {
|
||||
responsiblePerson: '',
|
||||
releaseProcess: '',
|
||||
linkedVVTActivityIds: [],
|
||||
linkedVendorIds: [],
|
||||
status: 'DRAFT',
|
||||
lastReviewDate: now,
|
||||
nextReviewDate: nextYear.toISOString(),
|
||||
|
||||
395
admin-compliance/lib/sdk/obligations-compliance.ts
Normal file
395
admin-compliance/lib/sdk/obligations-compliance.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
// =============================================================================
|
||||
// Obligations Module - Compliance Check Engine
|
||||
// Prueft Pflichten auf Vollstaendigkeit, Konsistenz und Auditfaehigkeit
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface Obligation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
source_article: string
|
||||
deadline: string | null
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
responsible: string
|
||||
linked_systems: string[]
|
||||
linked_vendor_ids?: string[]
|
||||
assessment_id?: string
|
||||
rule_code?: string
|
||||
notes?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
evidence?: string[]
|
||||
review_date?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export type ObligationComplianceIssueType =
|
||||
| 'MISSING_RESPONSIBLE'
|
||||
| 'OVERDUE_DEADLINE'
|
||||
| 'MISSING_EVIDENCE'
|
||||
| 'MISSING_DESCRIPTION'
|
||||
| 'NO_LEGAL_REFERENCE'
|
||||
| 'INCOMPLETE_REGULATION'
|
||||
| 'HIGH_PRIORITY_NOT_STARTED'
|
||||
| 'STALE_PENDING'
|
||||
| 'MISSING_LINKED_SYSTEMS'
|
||||
| 'NO_REVIEW_PROCESS'
|
||||
| 'CRITICAL_WITHOUT_EVIDENCE'
|
||||
| 'MISSING_VENDOR_LINK'
|
||||
|
||||
export type ObligationComplianceIssueSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
|
||||
export interface ObligationComplianceIssue {
|
||||
type: ObligationComplianceIssueType
|
||||
severity: ObligationComplianceIssueSeverity
|
||||
message: string
|
||||
affectedObligations: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface ObligationComplianceCheckResult {
|
||||
score: number
|
||||
issues: ObligationComplianceIssue[]
|
||||
summary: { total: number; critical: number; high: number; medium: number; low: number }
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const OBLIGATION_SEVERITY_LABELS_DE: Record<ObligationComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export const OBLIGATION_SEVERITY_COLORS: Record<ObligationComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function daysBetween(date: Date, now: Date): number {
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PER-OBLIGATION CHECKS (1-5, 9, 11)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
|
||||
* Pflicht ohne verantwortliche Person/Abteilung.
|
||||
*/
|
||||
function checkMissingResponsible(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.responsible || o.responsible.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_RESPONSIBLE',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne verantwortliche Person oder Abteilung. Ohne klare Zustaendigkeit koennen Pflichten nicht zuverlaessig umgesetzt werden.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Weisen Sie jeder Pflicht eine verantwortliche Person oder Abteilung zu.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: OVERDUE_DEADLINE (HIGH)
|
||||
* Pflicht mit Deadline in der Vergangenheit + Status != completed.
|
||||
*/
|
||||
function checkOverdueDeadline(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (!o.deadline || o.status === 'completed') return false
|
||||
return new Date(o.deadline) < now
|
||||
})
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'OVERDUE_DEADLINE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} Pflicht(en) mit ueberschrittener Frist. Ueberfaellige Pflichten stellen ein Compliance-Risiko dar und koennen zu Bussgeldern fuehren.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Bearbeiten Sie ueberfaellige Pflichten umgehend oder passen Sie die Fristen an.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: MISSING_EVIDENCE (HIGH)
|
||||
* Completed-Pflicht ohne Evidence.
|
||||
*/
|
||||
function checkMissingEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.status === 'completed' && (!o.evidence || o.evidence.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_EVIDENCE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} abgeschlossene Pflicht(en) ohne Nachweis. Ohne Nachweise ist die Erfuellung im Audit nicht belegbar.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Hinterlegen Sie Nachweisdokumente fuer alle abgeschlossenen Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 4: MISSING_DESCRIPTION (MEDIUM)
|
||||
* Pflicht ohne Beschreibung.
|
||||
*/
|
||||
function checkMissingDescription(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.description || o.description.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_DESCRIPTION',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne Beschreibung. Eine fehlende Beschreibung erschwert die Nachvollziehbarkeit und Umsetzung.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ergaenzen Sie eine Beschreibung fuer jede Pflicht, die den Inhalt und die Anforderungen erlaeutert.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: NO_LEGAL_REFERENCE (HIGH)
|
||||
* Pflicht ohne source_article (kein Artikel-Bezug).
|
||||
*/
|
||||
function checkNoLegalReference(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.source_article || o.source_article.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'NO_LEGAL_REFERENCE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} Pflicht(en) ohne Artikel-/Paragraphen-Referenz. Ohne Rechtsbezug ist die Pflicht im Audit nicht nachvollziehbar.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ergaenzen Sie die Rechtsgrundlage (z.B. Art. 32 DSGVO) fuer jede Pflicht.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: MISSING_LINKED_SYSTEMS (MEDIUM)
|
||||
* Pflicht ohne verknuepfte Systeme/Verarbeitungen.
|
||||
*/
|
||||
function checkMissingLinkedSystems(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.linked_systems || o.linked_systems.length === 0)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_LINKED_SYSTEMS',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne verknuepfte Systeme oder Verarbeitungstaetigkeiten. Ohne Systemzuordnung fehlt der operative Bezug.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ordnen Sie jeder Pflicht die betroffenen IT-Systeme oder Verarbeitungstaetigkeiten zu.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: CRITICAL_WITHOUT_EVIDENCE (CRITICAL)
|
||||
* Critical-Pflicht ohne Evidence.
|
||||
*/
|
||||
function checkCriticalWithoutEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.priority === 'critical' && (!o.evidence || o.evidence.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'CRITICAL_WITHOUT_EVIDENCE',
|
||||
severity: 'CRITICAL',
|
||||
message: `${affected.length} kritische Pflicht(en) ohne Nachweis. Kritische Pflichten erfordern zwingend eine Dokumentation der Erfuellung.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Hinterlegen Sie umgehend Nachweise fuer alle kritischen Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 12: MISSING_VENDOR_LINK (MEDIUM)
|
||||
* Art.-28-Pflicht ohne verknuepften Auftragsverarbeiter.
|
||||
*/
|
||||
function checkMissingVendorLink(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.source_article?.includes('Art. 28') &&
|
||||
(!o.linked_vendor_ids || o.linked_vendor_ids.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
return {
|
||||
type: 'MISSING_VENDOR_LINK',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Art.-28-Pflicht(en) ohne verknuepften Auftragsverarbeiter.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Verknuepfen Sie Art.-28-Pflichten mit den betroffenen Auftragsverarbeitern im Vendor Register.',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AGGREGATE CHECKS (6-8, 10)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 6: INCOMPLETE_REGULATION (HIGH)
|
||||
* Regulierung, bei der alle Pflichten pending/overdue sind.
|
||||
*/
|
||||
function checkIncompleteRegulation(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const bySource = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const src = o.source || 'Unbekannt'
|
||||
if (!bySource.has(src)) bySource.set(src, [])
|
||||
bySource.get(src)!.push(o)
|
||||
}
|
||||
|
||||
const incompleteRegs: string[] = []
|
||||
const affectedIds: string[] = []
|
||||
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
if (obls.length < 2) continue // Skip single-obligation regulations
|
||||
const allStalled = obls.every(o => o.status === 'pending' || o.status === 'overdue')
|
||||
if (allStalled) {
|
||||
incompleteRegs.push(source)
|
||||
affectedIds.push(...obls.map(o => o.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (incompleteRegs.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'INCOMPLETE_REGULATION',
|
||||
severity: 'HIGH',
|
||||
message: `${incompleteRegs.length} Regulierung(en) vollstaendig ohne Umsetzung: ${incompleteRegs.join(', ')}. Alle Pflichten sind ausstehend oder ueberfaellig.`,
|
||||
affectedObligations: affectedIds,
|
||||
recommendation: 'Beginnen Sie mit der Umsetzung der wichtigsten Pflichten in den betroffenen Regulierungen.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: HIGH_PRIORITY_NOT_STARTED (CRITICAL)
|
||||
* Critical/High-Pflicht seit > 30 Tagen pending.
|
||||
*/
|
||||
function checkHighPriorityNotStarted(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (o.status !== 'pending') return false
|
||||
if (o.priority !== 'critical' && o.priority !== 'high') return false
|
||||
if (!o.created_at) return false
|
||||
return daysBetween(new Date(o.created_at), now) > 30
|
||||
})
|
||||
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'HIGH_PRIORITY_NOT_STARTED',
|
||||
severity: 'CRITICAL',
|
||||
message: `${affected.length} hochprioritaere Pflicht(en) seit ueber 30 Tagen nicht begonnen. Dies deutet auf organisatorische Blockaden oder fehlende Priorisierung hin.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Starten Sie umgehend mit der Bearbeitung dieser kritischen/hohen Pflichten und erstellen Sie einen Umsetzungsplan.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: STALE_PENDING (LOW)
|
||||
* Pflicht seit > 90 Tagen pending.
|
||||
*/
|
||||
function checkStalePending(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (o.status !== 'pending') return false
|
||||
if (!o.created_at) return false
|
||||
return daysBetween(new Date(o.created_at), now) > 90
|
||||
})
|
||||
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'STALE_PENDING',
|
||||
severity: 'LOW',
|
||||
message: `${affected.length} Pflicht(en) seit ueber 90 Tagen ausstehend. Langfristig unbearbeitete Pflichten sollten priorisiert oder als nicht relevant markiert werden.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Pruefen Sie, ob die Pflichten weiterhin relevant sind, und setzen Sie Prioritaeten fuer die Umsetzung.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: NO_REVIEW_PROCESS (MEDIUM)
|
||||
* Keine einzige Pflicht hat review_date.
|
||||
*/
|
||||
function checkNoReviewProcess(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
if (obligations.length === 0) return null
|
||||
const hasAnyReview = obligations.some(o => o.review_date)
|
||||
if (hasAnyReview) return null
|
||||
|
||||
return {
|
||||
type: 'NO_REVIEW_PROCESS',
|
||||
severity: 'MEDIUM',
|
||||
message: 'Keine Pflicht hat ein Pruefungsdatum (review_date). Ohne regelmaessige Ueberpruefung ist die Aktualitaet des Pflichtenregisters nicht gewaehrleistet.',
|
||||
affectedObligations: [],
|
||||
recommendation: 'Fuehren Sie ein Pruefintervall ein und setzen Sie review_date fuer alle Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Pflichten durch.
|
||||
*/
|
||||
export function runObligationComplianceCheck(obligations: Obligation[]): ObligationComplianceCheckResult {
|
||||
const issues: ObligationComplianceIssue[] = []
|
||||
|
||||
const checks = [
|
||||
checkMissingResponsible(obligations),
|
||||
checkOverdueDeadline(obligations),
|
||||
checkMissingEvidence(obligations),
|
||||
checkMissingDescription(obligations),
|
||||
checkNoLegalReference(obligations),
|
||||
checkIncompleteRegulation(obligations),
|
||||
checkHighPriorityNotStarted(obligations),
|
||||
checkStalePending(obligations),
|
||||
checkMissingLinkedSystems(obligations),
|
||||
checkNoReviewProcess(obligations),
|
||||
checkCriticalWithoutEvidence(obligations),
|
||||
checkMissingVendorLink(obligations),
|
||||
]
|
||||
|
||||
for (const issue of checks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
const summary = { total: issues.length, critical: 0, high: 0, medium: 0, low: 0 }
|
||||
for (const issue of issues) {
|
||||
switch (issue.severity) {
|
||||
case 'CRITICAL': summary.critical++; break
|
||||
case 'HIGH': summary.high++; break
|
||||
case 'MEDIUM': summary.medium++; break
|
||||
case 'LOW': summary.low++; break
|
||||
}
|
||||
}
|
||||
|
||||
const rawScore = 100 - (summary.critical * 15 + summary.high * 10 + summary.medium * 5 + summary.low * 2)
|
||||
const score = Math.max(0, rawScore)
|
||||
|
||||
return {
|
||||
score,
|
||||
issues,
|
||||
summary,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
915
admin-compliance/lib/sdk/obligations-document.ts
Normal file
915
admin-compliance/lib/sdk/obligations-document.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
// =============================================================================
|
||||
// Obligations Module - Pflichtenregister Document Generator
|
||||
// Generates a printable, audit-ready HTML document for the obligation register
|
||||
// =============================================================================
|
||||
|
||||
import type { Obligation, ObligationComplianceCheckResult, ObligationComplianceIssueSeverity } from './obligations-compliance'
|
||||
import { OBLIGATION_SEVERITY_LABELS_DE, OBLIGATION_SEVERITY_COLORS } from './obligations-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationDocumentOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
legalDepartment: string
|
||||
documentVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface ObligationDocumentRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultObligationDocumentOrgHeader(): ObligationDocumentOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
legalDepartment: '',
|
||||
documentVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATUS & PRIORITY LABELS
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_LABELS_DE: Record<string, string> = {
|
||||
'pending': 'Ausstehend',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
'completed': 'Abgeschlossen',
|
||||
'overdue': 'Ueberfaellig',
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
'pending': 'badge-draft',
|
||||
'in-progress': 'badge-review',
|
||||
'completed': 'badge-active',
|
||||
'overdue': 'badge-critical',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS_DE: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const PRIORITY_BADGE_CLASSES: Record<string, string> = {
|
||||
critical: 'badge-critical',
|
||||
high: 'badge-high',
|
||||
medium: 'badge-medium',
|
||||
low: 'badge-low',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildObligationDocumentHtml(
|
||||
obligations: Obligation[],
|
||||
orgHeader: ObligationDocumentOrgHeader,
|
||||
complianceResult: ObligationComplianceCheckResult | null,
|
||||
revisions: ObligationDocumentRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Group obligations by source (regulation)
|
||||
const bySource = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const src = o.source || 'Sonstig'
|
||||
if (!bySource.has(src)) bySource.set(src, [])
|
||||
bySource.get(src)!.push(o)
|
||||
}
|
||||
|
||||
// Build role map
|
||||
const roleMap = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const role = o.responsible || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
roleMap.get(role)!.push(o)
|
||||
}
|
||||
|
||||
// Distinct sources
|
||||
const distinctSources = Array.from(bySource.keys()).sort()
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pflichtenregister — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>Pflichtenregister</h1>
|
||||
<div class="subtitle">Regulatorische Pflichten — DSGVO, AI Act, NIS2 und weitere</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.legalDepartment ? `<div><span class="label">Rechtsabteilung:</span> ${escHtml(orgHeader.legalDepartment)}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Methodik',
|
||||
'Regulatorische Grundlagen',
|
||||
'Pflichtenuebersicht',
|
||||
'Detaillierte Pflichten',
|
||||
'Verantwortlichkeiten',
|
||||
'Fristen und Termine',
|
||||
'Nachweisverzeichnis',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Pflichtenregister dokumentiert alle regulatorischen Pflichten, denen
|
||||
<strong>${escHtml(orgName)}</strong> unterliegt. Es dient der systematischen Erfassung,
|
||||
Ueberwachung und Nachverfolgung aller Compliance-Anforderungen aus den anwendbaren
|
||||
Regulierungen.</p>
|
||||
<p style="margin-top: 8px;">Das Register erfuellt folgende Zwecke:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendige Erfassung aller anwendbaren regulatorischen Pflichten</li>
|
||||
<li>Zuordnung von Verantwortlichkeiten und Fristen</li>
|
||||
<li>Nachverfolgung des Umsetzungsstatus</li>
|
||||
<li>Dokumentation von Nachweisen fuer Audits</li>
|
||||
<li>Identifikation von Compliance-Luecken und Handlungsbedarf</li>
|
||||
</ul>
|
||||
<table>
|
||||
<tr><th>Rechtsrahmen</th><th>Relevanz</th></tr>
|
||||
<tr><td><strong>DSGVO (EU) 2016/679</strong></td><td>Datenschutz-Grundverordnung — Kernregulierung fuer personenbezogene Daten</td></tr>
|
||||
<tr><td><strong>AI Act (EU) 2024/1689</strong></td><td>KI-Verordnung — Anforderungen an KI-Systeme nach Risikoklasse</td></tr>
|
||||
<tr><td><strong>NIS2 (EU) 2022/2555</strong></td><td>Netzwerk- und Informationssicherheit — Cybersicherheitspflichten</td></tr>
|
||||
<tr><td><strong>BDSG</strong></td><td>Bundesdatenschutzgesetz — Nationale Ergaenzung zur DSGVO</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Pflichtenregister gilt fuer alle Geschaeftsprozesse und IT-Systeme von
|
||||
<strong>${escHtml(orgName)}</strong>${orgHeader.industry ? ` (Branche: ${escHtml(orgHeader.industry)})` : ''}.</p>
|
||||
<p style="margin-top: 8px;">Anwendbare Regulierungen:</p>
|
||||
<table>
|
||||
<tr><th>Regulierung</th><th>Anzahl Pflichten</th><th>Status</th></tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
const pct = obls.length > 0 ? Math.round((completed / obls.length) * 100) : 0
|
||||
html += ` <tr>
|
||||
<td>${escHtml(source)}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${completed}/${obls.length} abgeschlossen (${pct}%)</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
<p>Insgesamt umfasst dieses Register <strong>${obligations.length}</strong> Pflichten aus
|
||||
<strong>${distinctSources.length}</strong> Regulierungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Methodik
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Methodik</div>
|
||||
<div class="section-body">
|
||||
<p>Die Identifikation und Bewertung der Pflichten erfolgt in drei Schritten:</p>
|
||||
<div class="principle"><strong>Pflicht-Identifikation:</strong> Systematische Analyse aller anwendbaren Regulierungen und Extraktion der einzelnen Pflichten mit Artikel-Referenz, Beschreibung und Zielgruppe.</div>
|
||||
<div class="principle"><strong>Bewertung und Priorisierung:</strong> Jede Pflicht wird nach Prioritaet (kritisch, hoch, mittel, niedrig) und Dringlichkeit (Frist) bewertet. Die Bewertung basiert auf dem Risikopotenzial bei Nichterfuellung.</div>
|
||||
<div class="principle"><strong>Ueberwachung und Nachverfolgung:</strong> Regelmaessige Pruefung des Umsetzungsstatus, Aktualisierung der Fristen und Dokumentation von Nachweisen.</div>
|
||||
<p style="margin-top: 12px;">Die Pflichten werden ueber einen automatisierten Compliance-Check geprueft, der
|
||||
11 Kriterien umfasst (siehe Abschnitt 10: Compliance-Status).</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Regulatorische Grundlagen
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">4. Regulatorische Grundlagen</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die regulatorischen Grundlagen mit Artikelzahl und Umsetzungsstatus:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regulierung</th>
|
||||
<th>Pflichten</th>
|
||||
<th>Kritisch</th>
|
||||
<th>Hoch</th>
|
||||
<th>Mittel</th>
|
||||
<th>Niedrig</th>
|
||||
<th>Abgeschlossen</th>
|
||||
</tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const critical = obls.filter(o => o.priority === 'critical').length
|
||||
const high = obls.filter(o => o.priority === 'high').length
|
||||
const medium = obls.filter(o => o.priority === 'medium').length
|
||||
const low = obls.filter(o => o.priority === 'low').length
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
|
||||
html += ` <tr>
|
||||
<td><strong>${escHtml(source)}</strong></td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${critical}</td>
|
||||
<td>${high}</td>
|
||||
<td>${medium}</td>
|
||||
<td>${low}</td>
|
||||
<td>${completed}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
// Totals row
|
||||
const totalCritical = obligations.filter(o => o.priority === 'critical').length
|
||||
const totalHigh = obligations.filter(o => o.priority === 'high').length
|
||||
const totalMedium = obligations.filter(o => o.priority === 'medium').length
|
||||
const totalLow = obligations.filter(o => o.priority === 'low').length
|
||||
const totalCompleted = obligations.filter(o => o.status === 'completed').length
|
||||
|
||||
html += ` <tr style="font-weight: 700; background: #f5f3ff;">
|
||||
<td>Gesamt</td>
|
||||
<td>${obligations.length}</td>
|
||||
<td>${totalCritical}</td>
|
||||
<td>${totalHigh}</td>
|
||||
<td>${totalMedium}</td>
|
||||
<td>${totalLow}</td>
|
||||
<td>${totalCompleted}</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Pflichtenuebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">5. Pflichtenuebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Uebersicht aller ${obligations.length} Pflichten nach Regulierung und Status:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regulierung</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Ausstehend</th>
|
||||
<th>In Bearbeitung</th>
|
||||
<th>Abgeschlossen</th>
|
||||
<th>Ueberfaellig</th>
|
||||
</tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const pending = obls.filter(o => o.status === 'pending').length
|
||||
const inProgress = obls.filter(o => o.status === 'in-progress').length
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
const overdue = obls.filter(o => o.status === 'overdue').length
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(source)}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${pending}</td>
|
||||
<td>${inProgress}</td>
|
||||
<td>${completed}</td>
|
||||
<td>${overdue > 0 ? `<span class="badge badge-critical">${overdue}</span>` : '0'}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: Detaillierte Pflichten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">6. Detaillierte Pflichten</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
// Sort by priority (critical first) then by title
|
||||
const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const sorted = [...obls].sort((a, b) => {
|
||||
const pa = priorityOrder[a.priority] ?? 2
|
||||
const pb = priorityOrder[b.priority] ?? 2
|
||||
if (pa !== pb) return pa - pb
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(source)} <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${sorted.length} Pflichten)</span></h3>
|
||||
`
|
||||
|
||||
for (const o of sorted) {
|
||||
const statusLabel = STATUS_LABELS_DE[o.status] || o.status
|
||||
const statusBadge = STATUS_BADGE_CLASSES[o.status] || 'badge-draft'
|
||||
const priorityLabel = PRIORITY_LABELS_DE[o.priority] || o.priority
|
||||
const priorityBadge = PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'
|
||||
const deadlineStr = o.deadline ? formatDateDE(o.deadline) : '—'
|
||||
const evidenceStr = o.evidence && o.evidence.length > 0
|
||||
? o.evidence.map(e => escHtml(e)).join(', ')
|
||||
: '<em style="color: #d97706;">Kein Nachweis</em>'
|
||||
const systemsStr = o.linked_systems && o.linked_systems.length > 0
|
||||
? o.linked_systems.map(s => escHtml(s)).join(', ')
|
||||
: '—'
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(o.title)}</span>
|
||||
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Rechtsquelle</th><td>${escHtml(o.source)} ${escHtml(o.source_article || '')}</td></tr>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(o.description || '—')}</td></tr>
|
||||
<tr><th>Prioritaet</th><td><span class="badge ${priorityBadge}">${escHtml(priorityLabel)}</span></td></tr>
|
||||
<tr><th>Status</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(o.responsible || '—')}</td></tr>
|
||||
<tr><th>Frist</th><td>${deadlineStr}</td></tr>
|
||||
<tr><th>Nachweise</th><td>${evidenceStr}</td></tr>
|
||||
<tr><th>Betroffene Systeme</th><td>${systemsStr}</td></tr>
|
||||
${o.linked_vendor_ids && o.linked_vendor_ids.length > 0 ? `<tr><th>Auftragsverarbeiter</th><td>${o.linked_vendor_ids.map(id => escHtml(id)).join(', ')}</td></tr>` : ''}
|
||||
${o.notes ? `<tr><th>Notizen</th><td>${escHtml(o.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">7. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Pflichten
|
||||
die Umsetzungsverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Verantwortlich</th><th>Pflichten</th><th>Anzahl</th><th>Davon offen</th></tr>
|
||||
`
|
||||
for (const [role, obls] of roleMap.entries()) {
|
||||
const openCount = obls.filter(o => o.status !== 'completed').length
|
||||
const titles = obls.slice(0, 5).map(o => escHtml(o.title))
|
||||
const suffix = obls.length > 5 ? `, ... (+${obls.length - 5})` : ''
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${titles.join('; ')}${suffix}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${openCount}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Fristen und Termine
|
||||
// =========================================================================
|
||||
const now = new Date()
|
||||
const withDeadline = obligations
|
||||
.filter(o => o.deadline && o.status !== 'completed')
|
||||
.sort((a, b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime())
|
||||
|
||||
const overdue = withDeadline.filter(o => new Date(o.deadline!) < now)
|
||||
const upcoming = withDeadline.filter(o => new Date(o.deadline!) >= now)
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Fristen und Termine</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (overdue.length > 0) {
|
||||
html += ` <h4 style="color: #dc2626; margin-bottom: 8px;">Ueberfaellige Pflichten (${overdue.length})</h4>
|
||||
<table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Tage ueberfaellig</th><th>Prioritaet</th></tr>
|
||||
`
|
||||
for (const o of overdue) {
|
||||
const days = daysBetween(new Date(o.deadline!), now)
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${formatDateDE(o.deadline)}</td>
|
||||
<td><span class="badge badge-critical">${days} Tage</span></td>
|
||||
<td><span class="badge ${PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'}">${escHtml(PRIORITY_LABELS_DE[o.priority] || o.priority)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
html += ` <h4 style="color: #5b21b6; margin: 16px 0 8px 0;">Anstehende Fristen (${upcoming.length})</h4>
|
||||
<table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Verbleibend</th><th>Verantwortlich</th></tr>
|
||||
`
|
||||
for (const o of upcoming.slice(0, 20)) {
|
||||
const days = daysBetween(now, new Date(o.deadline!))
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${formatDateDE(o.deadline)}</td>
|
||||
<td>${days} Tage</td>
|
||||
<td>${escHtml(o.responsible || '—')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
if (upcoming.length > 20) {
|
||||
html += ` <tr><td colspan="5" style="text-align: center; color: #64748b;">... und ${upcoming.length - 20} weitere</td></tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (withDeadline.length === 0) {
|
||||
html += ` <p><em>Keine offenen Pflichten mit Fristen vorhanden.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Nachweisverzeichnis
|
||||
// =========================================================================
|
||||
const withEvidence = obligations.filter(o => o.evidence && o.evidence.length > 0)
|
||||
const withoutEvidence = obligations.filter(o => !o.evidence || o.evidence.length === 0)
|
||||
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">9. Nachweisverzeichnis</div>
|
||||
<div class="section-body">
|
||||
<p>${withEvidence.length} von ${obligations.length} Pflichten haben Nachweise hinterlegt.</p>
|
||||
`
|
||||
if (withEvidence.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Nachweise</th><th>Status</th></tr>
|
||||
`
|
||||
for (const o of withEvidence) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${o.evidence!.map(e => escHtml(e)).join(', ')}</td>
|
||||
<td><span class="badge ${STATUS_BADGE_CLASSES[o.status] || 'badge-draft'}">${escHtml(STATUS_LABELS_DE[o.status] || o.status)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (withoutEvidence.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Pflichten ohne Nachweise (${withoutEvidence.length}):</strong></p>
|
||||
<ul style="margin: 4px 0 8px 24px; font-size: 9pt; color: #d97706;">
|
||||
`
|
||||
for (const o of withoutEvidence.slice(0, 15)) {
|
||||
html += ` <li>${escHtml(o.title)} (${escHtml(o.source)})</li>
|
||||
`
|
||||
}
|
||||
if (withoutEvidence.length > 15) {
|
||||
html += ` <li>... und ${withoutEvidence.length - 15} weitere</li>
|
||||
`
|
||||
}
|
||||
html += ` </ul>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">10. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Geprueft am</td><td>${formatDateDE(complianceResult.checkedAt)}</td></tr>
|
||||
<tr><td>Befunde gesamt</td><td>${complianceResult.summary.total}</td></tr>
|
||||
<tr><td>Kritisch</td><td>${complianceResult.summary.critical}</td></tr>
|
||||
<tr><td>Hoch</td><td>${complianceResult.summary.high}</td></tr>
|
||||
<tr><td>Mittel</td><td>${complianceResult.summary.medium}</td></tr>
|
||||
<tr><td>Niedrig</td><td>${complianceResult.summary.low}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Befund</th><th>Betroffene Pflichten</th><th>Empfehlung</th></tr>
|
||||
`
|
||||
const severityOrder: ObligationComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
for (const issue of issuesForSev) {
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${OBLIGATION_SEVERITY_COLORS[sev]}">${OBLIGATION_SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${escHtml(issue.message)}</td>
|
||||
<td>${issue.affectedObligations.length > 0 ? issue.affectedObligations.length + ' Pflicht(en)' : '—'}</td>
|
||||
<td>${escHtml(issue.recommendation)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Pflichten sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Pflichtenregister-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">11. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.documentVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '—')}</td>
|
||||
<td>Erstversion des Pflichtenregisters</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>Pflichtenregister — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
function daysBetween(earlier: Date, later: Date): number {
|
||||
const diffMs = later.getTime() - earlier.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
553
admin-compliance/lib/sdk/tom-compliance.ts
Normal file
553
admin-compliance/lib/sdk/tom-compliance.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
// =============================================================================
|
||||
// TOM Module - Compliance Check Engine
|
||||
// Prueft Technische und Organisatorische Massnahmen auf Vollstaendigkeit,
|
||||
// Konsistenz und DSGVO-Konformitaet (Art. 32 DSGVO)
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
TOMGeneratorState,
|
||||
DerivedTOM,
|
||||
RiskProfile,
|
||||
DataProfile,
|
||||
ControlCategory,
|
||||
ImplementationStatus,
|
||||
} from './tom-generator/types'
|
||||
|
||||
import { getControlById, getControlsByCategory, getAllCategories } from './tom-generator/controls/loader'
|
||||
import { SDM_CATEGORY_MAPPING } from './tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type TOMComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
||||
|
||||
export type TOMComplianceIssueType =
|
||||
| 'MISSING_RESPONSIBLE'
|
||||
| 'OVERDUE_REVIEW'
|
||||
| 'MISSING_EVIDENCE'
|
||||
| 'INCOMPLETE_CATEGORY'
|
||||
| 'NO_ENCRYPTION_MEASURES'
|
||||
| 'NO_PSEUDONYMIZATION'
|
||||
| 'MISSING_AVAILABILITY'
|
||||
| 'NO_REVIEW_PROCESS'
|
||||
| 'UNCOVERED_SDM_GOAL'
|
||||
| 'HIGH_RISK_WITHOUT_MEASURES'
|
||||
| 'STALE_NOT_IMPLEMENTED'
|
||||
|
||||
export interface TOMComplianceIssue {
|
||||
id: string
|
||||
controlId: string
|
||||
controlName: string
|
||||
type: TOMComplianceIssueType
|
||||
severity: TOMComplianceIssueSeverity
|
||||
title: string
|
||||
description: string
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface TOMComplianceCheckResult {
|
||||
issues: TOMComplianceIssue[]
|
||||
score: number // 0-100
|
||||
stats: {
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
bySeverity: Record<TOMComplianceIssueSeverity, number>
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const TOM_SEVERITY_LABELS_DE: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export const TOM_SEVERITY_COLORS: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
let issueCounter = 0
|
||||
|
||||
function createIssueId(): string {
|
||||
issueCounter++
|
||||
return `TCI-${Date.now()}-${String(issueCounter).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function createIssue(
|
||||
controlId: string,
|
||||
controlName: string,
|
||||
type: TOMComplianceIssueType,
|
||||
severity: TOMComplianceIssueSeverity,
|
||||
title: string,
|
||||
description: string,
|
||||
recommendation: string
|
||||
): TOMComplianceIssue {
|
||||
return { id: createIssueId(), controlId, controlName, type, severity, title, description, recommendation }
|
||||
}
|
||||
|
||||
function daysBetween(date: Date, now: Date): number {
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PER-TOM CHECKS (1-3, 11)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
|
||||
* REQUIRED TOM without responsiblePerson AND responsibleDepartment.
|
||||
*/
|
||||
function checkMissingResponsible(tom: DerivedTOM): TOMComplianceIssue | null {
|
||||
if (tom.applicability !== 'REQUIRED') return null
|
||||
|
||||
if (!tom.responsiblePerson && !tom.responsibleDepartment) {
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'MISSING_RESPONSIBLE',
|
||||
'MEDIUM',
|
||||
'Keine verantwortliche Person/Abteilung',
|
||||
`Die TOM "${tom.name}" ist als REQUIRED eingestuft, hat aber weder eine verantwortliche Person noch eine verantwortliche Abteilung zugewiesen. Ohne klare Verantwortlichkeit kann die Massnahme nicht zuverlaessig umgesetzt und gepflegt werden.`,
|
||||
'Weisen Sie eine verantwortliche Person oder Abteilung zu, die fuer die Umsetzung und regelmaessige Pruefung dieser Massnahme zustaendig ist.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: OVERDUE_REVIEW (MEDIUM)
|
||||
* TOM with reviewDate in the past.
|
||||
*/
|
||||
function checkOverdueReview(tom: DerivedTOM): TOMComplianceIssue | null {
|
||||
if (!tom.reviewDate) return null
|
||||
|
||||
const reviewDate = new Date(tom.reviewDate)
|
||||
const now = new Date()
|
||||
|
||||
if (reviewDate < now) {
|
||||
const overdueDays = daysBetween(reviewDate, now)
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'OVERDUE_REVIEW',
|
||||
'MEDIUM',
|
||||
'Ueberfaellige Pruefung',
|
||||
`Die TOM "${tom.name}" haette am ${reviewDate.toLocaleDateString('de-DE')} geprueft werden muessen. Die Pruefung ist ${overdueDays} Tag(e) ueberfaellig. Gemaess Art. 32 Abs. 1 lit. d DSGVO ist eine regelmaessige Ueberpruefung der Wirksamkeit von TOMs erforderlich.`,
|
||||
'Fuehren Sie umgehend eine Wirksamkeitspruefung dieser Massnahme durch und aktualisieren Sie das naechste Pruefungsdatum.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: MISSING_EVIDENCE (HIGH)
|
||||
* IMPLEMENTED TOM where linkedEvidence is empty but the control has evidenceRequirements.
|
||||
*/
|
||||
function checkMissingEvidence(tom: DerivedTOM): TOMComplianceIssue | null {
|
||||
if (tom.implementationStatus !== 'IMPLEMENTED') return null
|
||||
if (tom.linkedEvidence.length > 0) return null
|
||||
|
||||
const control = getControlById(tom.controlId)
|
||||
if (!control || control.evidenceRequirements.length === 0) return null
|
||||
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'MISSING_EVIDENCE',
|
||||
'HIGH',
|
||||
'Kein Nachweis hinterlegt',
|
||||
`Die TOM "${tom.name}" ist als IMPLEMENTED markiert, hat aber keine verknuepften Nachweisdokumente. Der Control erfordert ${control.evidenceRequirements.length} Nachweis(e): ${control.evidenceRequirements.join(', ')}. Ohne Nachweise ist die Umsetzung nicht auditfaehig.`,
|
||||
'Laden Sie die erforderlichen Nachweisdokumente hoch und verknuepfen Sie sie mit dieser Massnahme.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: STALE_NOT_IMPLEMENTED (LOW)
|
||||
* REQUIRED TOM that has been NOT_IMPLEMENTED for >90 days.
|
||||
* Uses implementationDate === null and state.createdAt / state.updatedAt as reference.
|
||||
*/
|
||||
function checkStaleNotImplemented(tom: DerivedTOM, state: TOMGeneratorState): TOMComplianceIssue | null {
|
||||
if (tom.applicability !== 'REQUIRED') return null
|
||||
if (tom.implementationStatus !== 'NOT_IMPLEMENTED') return null
|
||||
if (tom.implementationDate !== null) return null
|
||||
|
||||
const referenceDate = state.createdAt ? new Date(state.createdAt) : (state.updatedAt ? new Date(state.updatedAt) : null)
|
||||
if (!referenceDate) return null
|
||||
|
||||
const ageInDays = daysBetween(referenceDate, new Date())
|
||||
if (ageInDays <= 90) return null
|
||||
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'STALE_NOT_IMPLEMENTED',
|
||||
'LOW',
|
||||
'Langfristig nicht umgesetzte Pflichtmassnahme',
|
||||
`Die TOM "${tom.name}" ist als REQUIRED eingestuft, aber seit ${ageInDays} Tagen nicht umgesetzt. Pflichtmassnahmen, die laenger als 90 Tage nicht implementiert werden, deuten auf organisatorische Blockaden oder unzureichende Priorisierung hin.`,
|
||||
'Pruefen Sie, ob die Massnahme weiterhin erforderlich ist, und erstellen Sie einen konkreten Umsetzungsplan mit Verantwortlichkeiten und Fristen.'
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AGGREGATE CHECKS (4-10)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 4: INCOMPLETE_CATEGORY (HIGH)
|
||||
* Category where ALL applicable (REQUIRED) controls are NOT_IMPLEMENTED.
|
||||
*/
|
||||
function checkIncompleteCategory(toms: DerivedTOM[]): TOMComplianceIssue[] {
|
||||
const issues: TOMComplianceIssue[] = []
|
||||
|
||||
// Group applicable TOMs by category
|
||||
const categoryMap = new Map<ControlCategory, DerivedTOM[]>()
|
||||
|
||||
for (const tom of toms) {
|
||||
const control = getControlById(tom.controlId)
|
||||
if (!control) continue
|
||||
|
||||
const category = control.category
|
||||
if (!categoryMap.has(category)) {
|
||||
categoryMap.set(category, [])
|
||||
}
|
||||
categoryMap.get(category)!.push(tom)
|
||||
}
|
||||
|
||||
for (const [category, categoryToms] of Array.from(categoryMap.entries())) {
|
||||
// Only check categories that have at least one REQUIRED control
|
||||
const requiredToms = categoryToms.filter((t: DerivedTOM) => t.applicability === 'REQUIRED')
|
||||
if (requiredToms.length === 0) continue
|
||||
|
||||
const allNotImplemented = requiredToms.every((t: DerivedTOM) => t.implementationStatus === 'NOT_IMPLEMENTED')
|
||||
if (allNotImplemented) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
category,
|
||||
category,
|
||||
'INCOMPLETE_CATEGORY',
|
||||
'HIGH',
|
||||
`Kategorie "${category}" vollstaendig ohne Umsetzung`,
|
||||
`Alle ${requiredToms.length} Pflichtmassnahme(n) in der Kategorie "${category}" sind nicht umgesetzt. Eine vollstaendig unabgedeckte Kategorie stellt eine erhebliche Luecke im TOM-Konzept dar.`,
|
||||
`Setzen Sie mindestens die wichtigsten Massnahmen in der Kategorie "${category}" um, um eine Grundabdeckung sicherzustellen.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: NO_ENCRYPTION_MEASURES (CRITICAL)
|
||||
* No ENCRYPTION control with status IMPLEMENTED.
|
||||
*/
|
||||
function checkNoEncryption(toms: DerivedTOM[]): TOMComplianceIssue | null {
|
||||
const hasImplementedEncryption = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return control?.category === 'ENCRYPTION' && tom.implementationStatus === 'IMPLEMENTED'
|
||||
})
|
||||
|
||||
if (!hasImplementedEncryption) {
|
||||
return createIssue(
|
||||
'ENCRYPTION',
|
||||
'Verschluesselung',
|
||||
'NO_ENCRYPTION_MEASURES',
|
||||
'CRITICAL',
|
||||
'Keine Verschluesselungsmassnahmen umgesetzt',
|
||||
'Es ist keine einzige Verschluesselungsmassnahme als IMPLEMENTED markiert. Art. 32 Abs. 1 lit. a DSGVO nennt Verschluesselung explizit als geeignete technische Massnahme. Ohne Verschluesselung sind personenbezogene Daten bei Zugriff oder Verlust ungeschuetzt.',
|
||||
'Implementieren Sie umgehend Verschluesselungsmassnahmen fuer Daten im Ruhezustand (Encryption at Rest) und waehrend der Uebertragung (Encryption in Transit).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 6: NO_PSEUDONYMIZATION (MEDIUM)
|
||||
* DataProfile has special categories (Art. 9) but no PSEUDONYMIZATION control implemented.
|
||||
*/
|
||||
function checkNoPseudonymization(toms: DerivedTOM[], dataProfile: DataProfile | null): TOMComplianceIssue | null {
|
||||
if (!dataProfile || !dataProfile.hasSpecialCategories) return null
|
||||
|
||||
const hasImplementedPseudonymization = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return control?.category === 'PSEUDONYMIZATION' && tom.implementationStatus === 'IMPLEMENTED'
|
||||
})
|
||||
|
||||
if (!hasImplementedPseudonymization) {
|
||||
return createIssue(
|
||||
'PSEUDONYMIZATION',
|
||||
'Pseudonymisierung',
|
||||
'NO_PSEUDONYMIZATION',
|
||||
'MEDIUM',
|
||||
'Keine Pseudonymisierung bei besonderen Datenkategorien',
|
||||
'Das Datenprofil enthaelt besondere Kategorien personenbezogener Daten (Art. 9 DSGVO), aber keine Pseudonymisierungsmassnahme ist umgesetzt. Art. 32 Abs. 1 lit. a DSGVO empfiehlt Pseudonymisierung ausdruecklich als Schutzmassnahme.',
|
||||
'Implementieren Sie Pseudonymisierungsmassnahmen fuer die Verarbeitung besonderer Datenkategorien, um das Risiko fuer betroffene Personen zu minimieren.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: MISSING_AVAILABILITY (HIGH)
|
||||
* No AVAILABILITY or RECOVERY control implemented AND no DR plan in securityProfile.
|
||||
*/
|
||||
function checkMissingAvailability(toms: DerivedTOM[], state: TOMGeneratorState): TOMComplianceIssue | null {
|
||||
const hasAvailabilityOrRecovery = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return (
|
||||
(control?.category === 'AVAILABILITY' || control?.category === 'RECOVERY') &&
|
||||
tom.implementationStatus === 'IMPLEMENTED'
|
||||
)
|
||||
})
|
||||
|
||||
const hasDRPlan = state.securityProfile?.hasDRPlan ?? false
|
||||
|
||||
if (!hasAvailabilityOrRecovery && !hasDRPlan) {
|
||||
return createIssue(
|
||||
'AVAILABILITY',
|
||||
'Verfuegbarkeit / Wiederherstellbarkeit',
|
||||
'MISSING_AVAILABILITY',
|
||||
'HIGH',
|
||||
'Keine Verfuegbarkeits- oder Wiederherstellungsmassnahmen',
|
||||
'Weder Verfuegbarkeits- noch Wiederherstellungsmassnahmen sind umgesetzt, und es existiert kein Disaster-Recovery-Plan im Security-Profil. Art. 32 Abs. 1 lit. b und c DSGVO verlangen die Faehigkeit zur raschen Wiederherstellung der Verfuegbarkeit personenbezogener Daten.',
|
||||
'Implementieren Sie Backup-Konzepte, Redundanzloesungen und einen Disaster-Recovery-Plan, um die Verfuegbarkeit und Wiederherstellbarkeit sicherzustellen.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: NO_REVIEW_PROCESS (MEDIUM)
|
||||
* No REVIEW control implemented (Art. 32 Abs. 1 lit. d requires periodic review).
|
||||
*/
|
||||
function checkNoReviewProcess(toms: DerivedTOM[]): TOMComplianceIssue | null {
|
||||
const hasImplementedReview = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return control?.category === 'REVIEW' && tom.implementationStatus === 'IMPLEMENTED'
|
||||
})
|
||||
|
||||
if (!hasImplementedReview) {
|
||||
return createIssue(
|
||||
'REVIEW',
|
||||
'Ueberpruefung & Bewertung',
|
||||
'NO_REVIEW_PROCESS',
|
||||
'MEDIUM',
|
||||
'Kein Verfahren zur regelmaessigen Ueberpruefung',
|
||||
'Es ist keine Ueberpruefungsmassnahme als IMPLEMENTED markiert. Art. 32 Abs. 1 lit. d DSGVO verlangt ein Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit der technischen und organisatorischen Massnahmen.',
|
||||
'Implementieren Sie einen regelmaessigen Review-Prozess (z.B. quartalsweise TOM-Audits, jaehrliche Wirksamkeitspruefung) und dokumentieren Sie die Ergebnisse.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: UNCOVERED_SDM_GOAL (HIGH)
|
||||
* SDM goal with 0% coverage — no implemented control maps to it via SDM_CATEGORY_MAPPING.
|
||||
*/
|
||||
function checkUncoveredSDMGoal(toms: DerivedTOM[]): TOMComplianceIssue[] {
|
||||
const issues: TOMComplianceIssue[] = []
|
||||
|
||||
// Build reverse mapping: SDM goal -> ControlCategories that cover it
|
||||
const sdmGoals = [
|
||||
'Verfuegbarkeit',
|
||||
'Integritaet',
|
||||
'Vertraulichkeit',
|
||||
'Nichtverkettung',
|
||||
'Intervenierbarkeit',
|
||||
'Transparenz',
|
||||
'Datenminimierung',
|
||||
] as const
|
||||
|
||||
const goalToCategoriesMap = new Map<string, ControlCategory[]>()
|
||||
for (const goal of sdmGoals) {
|
||||
goalToCategoriesMap.set(goal, [])
|
||||
}
|
||||
|
||||
// Build reverse lookup from SDM_CATEGORY_MAPPING
|
||||
for (const [category, goals] of Object.entries(SDM_CATEGORY_MAPPING)) {
|
||||
for (const goal of goals) {
|
||||
const existing = goalToCategoriesMap.get(goal)
|
||||
if (existing) {
|
||||
existing.push(category as ControlCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect implemented categories
|
||||
const implementedCategories = new Set<ControlCategory>()
|
||||
for (const tom of toms) {
|
||||
if (tom.implementationStatus !== 'IMPLEMENTED') continue
|
||||
const control = getControlById(tom.controlId)
|
||||
if (control) {
|
||||
implementedCategories.add(control.category)
|
||||
}
|
||||
}
|
||||
|
||||
// Check each SDM goal
|
||||
for (const goal of sdmGoals) {
|
||||
const coveringCategories = goalToCategoriesMap.get(goal) ?? []
|
||||
const hasCoverage = coveringCategories.some((cat) => implementedCategories.has(cat))
|
||||
|
||||
if (!hasCoverage) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
`SDM-${goal}`,
|
||||
goal,
|
||||
'UNCOVERED_SDM_GOAL',
|
||||
'HIGH',
|
||||
`SDM-Gewaehrleistungsziel "${goal}" nicht abgedeckt`,
|
||||
`Das Gewaehrleistungsziel "${goal}" des Standard-Datenschutzmodells (SDM) ist durch keine umgesetzte Massnahme abgedeckt. Zugehoerige Kategorien (${coveringCategories.join(', ')}) haben keine IMPLEMENTED Controls. Das SDM ist die anerkannte Methodik zur Umsetzung der DSGVO-Anforderungen.`,
|
||||
`Setzen Sie mindestens eine Massnahme aus den Kategorien ${coveringCategories.join(', ')} um, um das SDM-Ziel "${goal}" abzudecken.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: HIGH_RISK_WITHOUT_MEASURES (CRITICAL)
|
||||
* Protection level VERY_HIGH but < 50% of REQUIRED controls implemented.
|
||||
*/
|
||||
function checkHighRiskWithoutMeasures(toms: DerivedTOM[], riskProfile: RiskProfile | null): TOMComplianceIssue | null {
|
||||
if (!riskProfile || riskProfile.protectionLevel !== 'VERY_HIGH') return null
|
||||
|
||||
const requiredToms = toms.filter((t) => t.applicability === 'REQUIRED')
|
||||
if (requiredToms.length === 0) return null
|
||||
|
||||
const implementedCount = requiredToms.filter((t) => t.implementationStatus === 'IMPLEMENTED').length
|
||||
const implementationRate = implementedCount / requiredToms.length
|
||||
|
||||
if (implementationRate < 0.5) {
|
||||
const percentage = Math.round(implementationRate * 100)
|
||||
return createIssue(
|
||||
'RISK-PROFILE',
|
||||
'Risikoprofil VERY_HIGH',
|
||||
'HIGH_RISK_WITHOUT_MEASURES',
|
||||
'CRITICAL',
|
||||
'Sehr hoher Schutzbedarf bei niedriger Umsetzungsrate',
|
||||
`Der Schutzbedarf ist als VERY_HIGH eingestuft, aber nur ${implementedCount} von ${requiredToms.length} Pflichtmassnahmen (${percentage}%) sind umgesetzt. Bei sehr hohem Schutzbedarf muessen mindestens 50% der Pflichtmassnahmen implementiert sein, um ein angemessenes Schutzniveau gemaess Art. 32 DSGVO zu gewaehrleisten.`,
|
||||
'Priorisieren Sie die Umsetzung der verbleibenden Pflichtmassnahmen. Beginnen Sie mit CRITICAL- und HIGH-Priority Controls. Erwaeegen Sie einen Umsetzungsplan mit klaren Meilensteinen.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fuehrt einen vollstaendigen Compliance-Check ueber alle TOMs durch.
|
||||
*
|
||||
* @param state - Der vollstaendige TOMGeneratorState
|
||||
* @returns TOMComplianceCheckResult mit Issues, Score und Statistiken
|
||||
*/
|
||||
export function runTOMComplianceCheck(state: TOMGeneratorState): TOMComplianceCheckResult {
|
||||
// Reset counter for deterministic IDs within a single check run
|
||||
issueCounter = 0
|
||||
|
||||
const issues: TOMComplianceIssue[] = []
|
||||
|
||||
// Filter to applicable TOMs only (REQUIRED or RECOMMENDED, exclude NOT_APPLICABLE)
|
||||
const applicableTOMs = state.derivedTOMs.filter(
|
||||
(tom) => tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
|
||||
// Run per-TOM checks (1-3, 11) on each applicable TOM
|
||||
for (const tom of applicableTOMs) {
|
||||
const perTomChecks = [
|
||||
checkMissingResponsible(tom),
|
||||
checkOverdueReview(tom),
|
||||
checkMissingEvidence(tom),
|
||||
checkStaleNotImplemented(tom, state),
|
||||
]
|
||||
|
||||
for (const issue of perTomChecks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run aggregate checks (4-10)
|
||||
issues.push(...checkIncompleteCategory(applicableTOMs))
|
||||
|
||||
const aggregateChecks = [
|
||||
checkNoEncryption(applicableTOMs),
|
||||
checkNoPseudonymization(applicableTOMs, state.dataProfile),
|
||||
checkMissingAvailability(applicableTOMs, state),
|
||||
checkNoReviewProcess(applicableTOMs),
|
||||
checkHighRiskWithoutMeasures(applicableTOMs, state.riskProfile),
|
||||
]
|
||||
|
||||
for (const issue of aggregateChecks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...checkUncoveredSDMGoal(applicableTOMs))
|
||||
|
||||
// Calculate score
|
||||
const bySeverity: Record<TOMComplianceIssueSeverity, number> = {
|
||||
LOW: 0,
|
||||
MEDIUM: 0,
|
||||
HIGH: 0,
|
||||
CRITICAL: 0,
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
bySeverity[issue.severity]++
|
||||
}
|
||||
|
||||
const rawScore =
|
||||
100 -
|
||||
(bySeverity.CRITICAL * 15 +
|
||||
bySeverity.HIGH * 10 +
|
||||
bySeverity.MEDIUM * 5 +
|
||||
bySeverity.LOW * 2)
|
||||
|
||||
const score = Math.max(0, rawScore)
|
||||
|
||||
// Calculate pass/fail per TOM
|
||||
const failedControlIds = new Set(
|
||||
issues.filter((i) => !i.controlId.startsWith('SDM-') && i.controlId !== 'RISK-PROFILE').map((i) => i.controlId)
|
||||
)
|
||||
const totalTOMs = applicableTOMs.length
|
||||
const failedCount = failedControlIds.size
|
||||
const passedCount = Math.max(0, totalTOMs - failedCount)
|
||||
|
||||
return {
|
||||
issues,
|
||||
score,
|
||||
stats: {
|
||||
total: totalTOMs,
|
||||
passed: passedCount,
|
||||
failed: failedCount,
|
||||
bySeverity,
|
||||
},
|
||||
}
|
||||
}
|
||||
906
admin-compliance/lib/sdk/tom-document.ts
Normal file
906
admin-compliance/lib/sdk/tom-document.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
// =============================================================================
|
||||
// TOM Module - TOM-Dokumentation Document Generator
|
||||
// Generates a printable, audit-ready HTML document according to DSGVO Art. 32
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
TOMGeneratorState,
|
||||
DerivedTOM,
|
||||
CompanyProfile,
|
||||
RiskProfile,
|
||||
ControlCategory,
|
||||
} from './tom-generator/types'
|
||||
|
||||
import { SDM_CATEGORY_MAPPING } from './tom-generator/types'
|
||||
|
||||
import {
|
||||
getControlById,
|
||||
getControlsByCategory,
|
||||
getAllCategories,
|
||||
getCategoryMetadata,
|
||||
} from './tom-generator/controls/loader'
|
||||
|
||||
import type { TOMComplianceCheckResult, TOMComplianceIssueSeverity } from './tom-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface TOMDocumentOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
itSecurityContact: string
|
||||
locations: string[]
|
||||
employeeCount: string
|
||||
documentVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface TOMDocumentRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultTOMDocumentOrgHeader(): TOMDocumentOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
itSecurityContact: '',
|
||||
locations: [],
|
||||
employeeCount: '',
|
||||
documentVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEVERITY LABELS (for Compliance Status section)
|
||||
// =============================================================================
|
||||
|
||||
const SEVERITY_LABELS_DE: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY LABELS (German)
|
||||
// =============================================================================
|
||||
|
||||
const CATEGORY_LABELS_DE: Record<ControlCategory, string> = {
|
||||
ACCESS_CONTROL: 'Zutrittskontrolle',
|
||||
ADMISSION_CONTROL: 'Zugangskontrolle',
|
||||
ACCESS_AUTHORIZATION: 'Zugriffskontrolle',
|
||||
TRANSFER_CONTROL: 'Weitergabekontrolle',
|
||||
INPUT_CONTROL: 'Eingabekontrolle',
|
||||
ORDER_CONTROL: 'Auftragskontrolle',
|
||||
AVAILABILITY: 'Verfuegbarkeit',
|
||||
SEPARATION: 'Trennbarkeit',
|
||||
ENCRYPTION: 'Verschluesselung',
|
||||
PSEUDONYMIZATION: 'Pseudonymisierung',
|
||||
RESILIENCE: 'Belastbarkeit',
|
||||
RECOVERY: 'Wiederherstellbarkeit',
|
||||
REVIEW: 'Ueberpruefung & Bewertung',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATUS & APPLICABILITY LABELS
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_LABELS_DE: Record<string, string> = {
|
||||
IMPLEMENTED: 'Umgesetzt',
|
||||
PARTIAL: 'Teilweise umgesetzt',
|
||||
NOT_IMPLEMENTED: 'Nicht umgesetzt',
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
IMPLEMENTED: 'badge-active',
|
||||
PARTIAL: 'badge-review',
|
||||
NOT_IMPLEMENTED: 'badge-critical',
|
||||
}
|
||||
|
||||
const APPLICABILITY_LABELS_DE: Record<string, string> = {
|
||||
REQUIRED: 'Erforderlich',
|
||||
RECOMMENDED: 'Empfohlen',
|
||||
OPTIONAL: 'Optional',
|
||||
NOT_APPLICABLE: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildTOMDocumentHtml(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
orgHeader: TOMDocumentOrgHeader,
|
||||
companyProfile: CompanyProfile | null,
|
||||
riskProfile: RiskProfile | null,
|
||||
complianceResult: TOMComplianceCheckResult | null,
|
||||
revisions: TOMDocumentRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Filter out NOT_APPLICABLE TOMs for display
|
||||
const applicableTOMs = derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
|
||||
|
||||
// Group TOMs by category via control library lookup
|
||||
const tomsByCategory = new Map<ControlCategory, DerivedTOM[]>()
|
||||
for (const tom of applicableTOMs) {
|
||||
const control = getControlById(tom.controlId)
|
||||
const cat = control?.category || 'REVIEW'
|
||||
if (!tomsByCategory.has(cat)) tomsByCategory.set(cat, [])
|
||||
tomsByCategory.get(cat)!.push(tom)
|
||||
}
|
||||
|
||||
// Build role map: role/department → list of control codes
|
||||
const roleMap = new Map<string, string[]>()
|
||||
for (const tom of applicableTOMs) {
|
||||
const role = tom.responsiblePerson || tom.responsibleDepartment || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
const control = getControlById(tom.controlId)
|
||||
roleMap.get(role)!.push(control?.code || tom.controlId)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>TOM-Dokumentation — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>TOM-Dokumentation</h1>
|
||||
<div class="subtitle">Technische und Organisatorische Massnahmen gemaess Art. 32 DSGVO</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.itSecurityContact ? `<div><span class="label">IT-Sicherheit:</span> ${escHtml(orgHeader.itSecurityContact)}</div>` : ''}
|
||||
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
|
||||
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Grundprinzipien Art. 32',
|
||||
'Schutzbedarf und Risikoanalyse',
|
||||
'Massnahmen-Uebersicht',
|
||||
'Detaillierte Massnahmen',
|
||||
'SDM Gewaehrleistungsziele',
|
||||
'Verantwortlichkeiten',
|
||||
'Pruef- und Revisionszyklus',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Diese TOM-Dokumentation beschreibt die technischen und organisatorischen Massnahmen
|
||||
zum Schutz personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Sie dient
|
||||
der Umsetzung folgender DSGVO-Anforderungen:</p>
|
||||
<table>
|
||||
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. a DSGVO</strong></td><td>Pseudonymisierung und Verschluesselung personenbezogener Daten</td></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. b DSGVO</strong></td><td>Faehigkeit, die Vertraulichkeit, Integritaet, Verfuegbarkeit und Belastbarkeit der Systeme und Dienste im Zusammenhang mit der Verarbeitung auf Dauer sicherzustellen</td></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. c DSGVO</strong></td><td>Faehigkeit, die Verfuegbarkeit der personenbezogenen Daten und den Zugang zu ihnen bei einem physischen oder technischen Zwischenfall rasch wiederherzustellen</td></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. d DSGVO</strong></td><td>Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit der technischen und organisatorischen Massnahmen</td></tr>
|
||||
</table>
|
||||
<p>Die TOM-Dokumentation ist fester Bestandteil des Datenschutz-Managementsystems und wird
|
||||
regelmaessig ueberprueft und aktualisiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
const industryInfo = companyProfile?.industry || orgHeader.industry || ''
|
||||
const hostingInfo = companyProfile ? `Unternehmen: ${escHtml(companyProfile.name || orgName)}, Groesse: ${escHtml(companyProfile.size || '-')}` : ''
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Diese TOM-Dokumentation gilt fuer alle IT-Systeme, Anwendungen und Verarbeitungsprozesse
|
||||
von <strong>${escHtml(orgName)}</strong>${industryInfo ? ` (Branche: ${escHtml(industryInfo)})` : ''}.</p>
|
||||
${hostingInfo ? `<p>${hostingInfo}</p>` : ''}
|
||||
${orgHeader.locations.length > 0 ? `<p>Standorte: ${escHtml(orgHeader.locations.join(', '))}</p>` : ''}
|
||||
<p>Die dokumentierten Massnahmen stammen aus zwei Quellen:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li><strong>Embedded Library (TOM-xxx):</strong> Integrierte Kontrollbibliothek mit spezifischen Massnahmen fuer Art. 32 DSGVO</li>
|
||||
<li><strong>Canonical Control Library (CP-CLIB):</strong> Uebergreifende Kontrollbibliothek mit framework-uebergreifenden Massnahmen</li>
|
||||
</ul>
|
||||
<p>Insgesamt umfasst dieses Dokument <strong>${applicableTOMs.length}</strong> anwendbare Massnahmen
|
||||
in <strong>${tomsByCategory.size}</strong> Kategorien.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Grundprinzipien Art. 32
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Grundprinzipien Art. 32</div>
|
||||
<div class="section-body">
|
||||
<div class="principle"><strong>Vertraulichkeit:</strong> Schutz personenbezogener Daten vor unbefugter Kenntnisnahme durch Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und Verschluesselung (Art. 32 Abs. 1 lit. b DSGVO).</div>
|
||||
<div class="principle"><strong>Integritaet:</strong> Sicherstellung, dass personenbezogene Daten nicht unbefugt oder unbeabsichtigt veraendert werden koennen, durch Eingabekontrolle, Weitergabekontrolle und Protokollierung (Art. 32 Abs. 1 lit. b DSGVO).</div>
|
||||
<div class="principle"><strong>Verfuegbarkeit und Belastbarkeit:</strong> Gewaehrleistung, dass Systeme und Dienste bei Lastspitzen und Stoerungen zuverlaessig funktionieren, durch Backup, Redundanz und Disaster Recovery (Art. 32 Abs. 1 lit. b DSGVO).</div>
|
||||
<div class="principle"><strong>Rasche Wiederherstellbarkeit:</strong> Faehigkeit, nach einem physischen oder technischen Zwischenfall Daten und Systeme schnell wiederherzustellen, durch getestete Recovery-Prozesse (Art. 32 Abs. 1 lit. c DSGVO).</div>
|
||||
<div class="principle"><strong>Regelmaessige Wirksamkeitspruefung:</strong> Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit aller technischen und organisatorischen Massnahmen (Art. 32 Abs. 1 lit. d DSGVO).</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Schutzbedarf und Risikoanalyse
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">4. Schutzbedarf und Risikoanalyse</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (riskProfile) {
|
||||
html += ` <p>Die folgende Schutzbedarfsanalyse bildet die Grundlage fuer die Auswahl und Priorisierung
|
||||
der technischen und organisatorischen Massnahmen:</p>
|
||||
<table>
|
||||
<tr><th>Kriterium</th><th>Bewertung</th></tr>
|
||||
<tr><td>Vertraulichkeit</td><td>${riskProfile.ciaAssessment.confidentiality}/5</td></tr>
|
||||
<tr><td>Integritaet</td><td>${riskProfile.ciaAssessment.integrity}/5</td></tr>
|
||||
<tr><td>Verfuegbarkeit</td><td>${riskProfile.ciaAssessment.availability}/5</td></tr>
|
||||
<tr><td>Schutzniveau</td><td><strong>${escHtml(riskProfile.protectionLevel)}</strong></td></tr>
|
||||
<tr><td>DSFA-Pflicht</td><td>${riskProfile.dsfaRequired ? 'Ja' : 'Nein'}</td></tr>
|
||||
${riskProfile.specialRisks.length > 0 ? `<tr><td>Spezialrisiken</td><td>${escHtml(riskProfile.specialRisks.join(', '))}</td></tr>` : ''}
|
||||
${riskProfile.regulatoryRequirements.length > 0 ? `<tr><td>Regulatorische Anforderungen</td><td>${escHtml(riskProfile.regulatoryRequirements.join(', '))}</td></tr>` : ''}
|
||||
</table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Die Schutzbedarfsanalyse wurde noch nicht durchgefuehrt. Fuehren Sie den
|
||||
Risiko-Wizard im TOM-Generator durch, um den Schutzbedarf zu ermitteln.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Massnahmen-Uebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">5. Massnahmen-Uebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${applicableTOMs.length} anwendbaren Massnahmen
|
||||
nach Kategorie:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Umgesetzt</th>
|
||||
<th>Teilweise</th>
|
||||
<th>Offen</th>
|
||||
</tr>
|
||||
`
|
||||
const allCategories = getAllCategories()
|
||||
for (const cat of allCategories) {
|
||||
const tomsInCat = tomsByCategory.get(cat)
|
||||
if (!tomsInCat || tomsInCat.length === 0) continue
|
||||
|
||||
const implemented = tomsInCat.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
const partial = tomsInCat.filter(t => t.implementationStatus === 'PARTIAL').length
|
||||
const notImpl = tomsInCat.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length
|
||||
const catLabel = CATEGORY_LABELS_DE[cat] || cat
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(catLabel)}</td>
|
||||
<td>${tomsInCat.length}</td>
|
||||
<td>${implemented}</td>
|
||||
<td>${partial}</td>
|
||||
<td>${notImpl}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: Detaillierte Massnahmen
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">6. Detaillierte Massnahmen</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
|
||||
for (const cat of allCategories) {
|
||||
const tomsInCat = tomsByCategory.get(cat)
|
||||
if (!tomsInCat || tomsInCat.length === 0) continue
|
||||
|
||||
const catLabel = CATEGORY_LABELS_DE[cat] || cat
|
||||
const catMeta = getCategoryMetadata(cat)
|
||||
const gdprRef = catMeta?.gdprReference || ''
|
||||
|
||||
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(catLabel)}${gdprRef ? ` <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${escHtml(gdprRef)})</span>` : ''}</h3>
|
||||
`
|
||||
|
||||
// Sort TOMs by control code
|
||||
const sortedTOMs = [...tomsInCat].sort((a, b) => {
|
||||
const codeA = getControlById(a.controlId)?.code || a.controlId
|
||||
const codeB = getControlById(b.controlId)?.code || b.controlId
|
||||
return codeA.localeCompare(codeB)
|
||||
})
|
||||
|
||||
for (const tom of sortedTOMs) {
|
||||
const control = getControlById(tom.controlId)
|
||||
const code = control?.code || tom.controlId
|
||||
const nameDE = control?.name?.de || tom.name
|
||||
const descDE = control?.description?.de || tom.description
|
||||
const typeLabel = control?.type === 'TECHNICAL' ? 'Technisch' : control?.type === 'ORGANIZATIONAL' ? 'Organisatorisch' : '-'
|
||||
const statusLabel = STATUS_LABELS_DE[tom.implementationStatus] || tom.implementationStatus
|
||||
const statusBadge = STATUS_BADGE_CLASSES[tom.implementationStatus] || 'badge-draft'
|
||||
const applicabilityLabel = APPLICABILITY_LABELS_DE[tom.applicability] || tom.applicability
|
||||
const responsible = [tom.responsiblePerson, tom.responsibleDepartment].filter(s => s && s.trim()).join(' / ') || '-'
|
||||
const implDate = tom.implementationDate ? formatDateDE(typeof tom.implementationDate === 'string' ? tom.implementationDate : tom.implementationDate.toISOString()) : '-'
|
||||
const reviewDate = tom.reviewDate ? formatDateDE(typeof tom.reviewDate === 'string' ? tom.reviewDate : tom.reviewDate.toISOString()) : '-'
|
||||
|
||||
// Evidence
|
||||
const evidenceInfo = tom.linkedEvidence.length > 0
|
||||
? tom.linkedEvidence.join(', ')
|
||||
: tom.evidenceGaps.length > 0
|
||||
? `<em style="color: #d97706;">Fehlend: ${escHtml(tom.evidenceGaps.join(', '))}</em>`
|
||||
: '-'
|
||||
|
||||
// Framework mappings
|
||||
let mappingsHtml = '-'
|
||||
if (control?.mappings && control.mappings.length > 0) {
|
||||
mappingsHtml = control.mappings.map(m => `${escHtml(m.framework)}: ${escHtml(m.reference)}`).join('<br/>')
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(code)} — ${escHtml(nameDE)}</span>
|
||||
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(descDE)}</td></tr>
|
||||
<tr><th>Massnahmentyp</th><td>${escHtml(typeLabel)}</td></tr>
|
||||
<tr><th>Anwendbarkeit</th><td>${escHtml(applicabilityLabel)}${tom.applicabilityReason ? ` — ${escHtml(tom.applicabilityReason)}` : ''}</td></tr>
|
||||
<tr><th>Umsetzungsstatus</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
|
||||
<tr><th>Umsetzungsdatum</th><td>${implDate}</td></tr>
|
||||
<tr><th>Naechste Pruefung</th><td>${reviewDate}</td></tr>
|
||||
<tr><th>Evidence</th><td>${evidenceInfo}</td></tr>
|
||||
<tr><th>Framework-Mappings</th><td>${mappingsHtml}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: SDM Gewaehrleistungsziele
|
||||
// =========================================================================
|
||||
const sdmGoals: Array<{ goal: string; categories: ControlCategory[] }> = []
|
||||
const allSDMGoals = [
|
||||
'Verfuegbarkeit',
|
||||
'Integritaet',
|
||||
'Vertraulichkeit',
|
||||
'Nichtverkettung',
|
||||
'Intervenierbarkeit',
|
||||
'Transparenz',
|
||||
'Datenminimierung',
|
||||
] as const
|
||||
|
||||
for (const goal of allSDMGoals) {
|
||||
const cats: ControlCategory[] = []
|
||||
for (const [cat, goals] of Object.entries(SDM_CATEGORY_MAPPING)) {
|
||||
if (goals.includes(goal)) {
|
||||
cats.push(cat as ControlCategory)
|
||||
}
|
||||
}
|
||||
sdmGoals.push({ goal, categories: cats })
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">7. SDM Gewaehrleistungsziele</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die Abdeckung der sieben Gewaehrleistungsziele des
|
||||
Standard-Datenschutzmodells (SDM) durch die implementierten Massnahmen:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Gewaehrleistungsziel</th>
|
||||
<th>Abgedeckt</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Abdeckung (%)</th>
|
||||
</tr>
|
||||
`
|
||||
for (const { goal, categories } of sdmGoals) {
|
||||
let totalInGoal = 0
|
||||
let implementedInGoal = 0
|
||||
for (const cat of categories) {
|
||||
const tomsInCat = tomsByCategory.get(cat) || []
|
||||
totalInGoal += tomsInCat.length
|
||||
implementedInGoal += tomsInCat.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
}
|
||||
const percentage = totalInGoal > 0 ? Math.round((implementedInGoal / totalInGoal) * 100) : 0
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(goal)}</td>
|
||||
<td>${implementedInGoal}</td>
|
||||
<td>${totalInGoal}</td>
|
||||
<td>${percentage}%</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Massnahmen
|
||||
die Umsetzungsverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Rolle / Verantwortlich</th><th>Massnahmen</th><th>Anzahl</th></tr>
|
||||
`
|
||||
for (const [role, controls] of roleMap.entries()) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${controls.map(c => escHtml(c)).join(', ')}</td>
|
||||
<td>${controls.length}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Pruef- und Revisionszyklus
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">9. Pruef- und Revisionszyklus</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
|
||||
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
|
||||
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
|
||||
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.documentVersion)}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 8px;">Bei jeder Pruefung wird die TOM-Dokumentation auf folgende Punkte ueberprueft:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendigkeit aller Massnahmen (neue Systeme oder Verarbeitungen erfasst?)</li>
|
||||
<li>Aktualitaet des Umsetzungsstatus (Aenderungen seit letzter Pruefung?)</li>
|
||||
<li>Wirksamkeit der technischen Massnahmen (Penetration-Tests, Audit-Ergebnisse)</li>
|
||||
<li>Angemessenheit der organisatorischen Massnahmen (Schulungen, Richtlinien aktuell?)</li>
|
||||
<li>Abdeckung aller SDM-Gewaehrleistungsziele</li>
|
||||
<li>Zuordnung von Verantwortlichkeiten zu allen Massnahmen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">10. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Gepruefte Massnahmen</td><td>${complianceResult.stats.total}</td></tr>
|
||||
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
|
||||
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
|
||||
`
|
||||
const severityOrder: TOMComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const count = complianceResult.stats.bySeverity[sev]
|
||||
if (count === 0) continue
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${count}</td>
|
||||
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Massnahmen sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">11. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.documentVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
|
||||
<td>Erstversion der TOM-Dokumentation</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>TOM-Dokumentation — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,9 @@ export interface ControlLibrary {
|
||||
|
||||
const CONTROL_LIBRARY_DATA: ControlLibrary = {
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2026-02-04',
|
||||
totalControls: 60,
|
||||
version: '1.1.0',
|
||||
lastUpdated: '2026-03-19',
|
||||
totalControls: 88,
|
||||
},
|
||||
categories: new Map([
|
||||
[
|
||||
@@ -2353,6 +2353,648 @@ const CONTROL_LIBRARY_DATA: ControlLibrary = {
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['training', 'security-awareness', 'phishing', 'social-engineering'],
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// NEW CONTROLS (v1.1.0) — 25 additional measures
|
||||
// =========================================================================
|
||||
|
||||
// ENCRYPTION — 2 new
|
||||
{
|
||||
id: 'TOM-ENC-04',
|
||||
code: 'TOM-ENC-04',
|
||||
category: 'ENCRYPTION',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Zertifikatsmanagement (TLS/SSL)', en: 'Certificate Management (TLS/SSL)' },
|
||||
description: {
|
||||
de: 'Systematische Verwaltung, Ueberwachung und rechtzeitige Erneuerung aller TLS/SSL-Zertifikate zur Vermeidung von Sicherheitsluecken durch abgelaufene Zertifikate.',
|
||||
en: 'Systematic management, monitoring and timely renewal of all TLS/SSL certificates to prevent security gaps from expired certificates.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.10.1.2' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'CON.1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.encryptionInTransit', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Zertifikatsinventar', 'Monitoring-Konfiguration', 'Erneuerungsprotokolle'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['encryption', 'certificates', 'tls'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-ENC-05',
|
||||
code: 'TOM-ENC-05',
|
||||
category: 'ENCRYPTION',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Schluesselmanagement-Policy', en: 'Key Management Policy' },
|
||||
description: {
|
||||
de: 'Dokumentierte Richtlinie fuer den gesamten Lebenszyklus kryptografischer Schluessel inkl. Erzeugung, Verteilung, Speicherung, Rotation und Vernichtung.',
|
||||
en: 'Documented policy for the full lifecycle of cryptographic keys including generation, distribution, storage, rotation and destruction.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.10.1.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.encryptionAtRest', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 30 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Schluesselmanagement-Richtlinie', 'Schluesselrotationsplan'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'LOW',
|
||||
tags: ['encryption', 'key-management', 'policy'],
|
||||
},
|
||||
|
||||
// PSEUDONYMIZATION — 2 new
|
||||
{
|
||||
id: 'TOM-PS-03',
|
||||
code: 'TOM-PS-03',
|
||||
category: 'PSEUDONYMIZATION',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Anonymisierung fuer Analysezwecke', en: 'Anonymization for Analytics' },
|
||||
description: {
|
||||
de: 'Technische Verfahren zur irreversiblen Anonymisierung personenbezogener Daten fuer statistische Auswertungen und Analysen.',
|
||||
en: 'Technical procedures for irreversible anonymization of personal data for statistical evaluations and analyses.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
|
||||
{ framework: 'GDPR_ART25', reference: 'Art. 25 Abs. 1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'dataProfile.dataVolume', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'RECOMMENDED', priority: 15 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['Anonymisierungsverfahren-Dokumentation', 'Re-Identifizierungs-Risikoanalyse'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'HIGH',
|
||||
tags: ['pseudonymization', 'anonymization', 'analytics'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-PS-04',
|
||||
code: 'TOM-PS-04',
|
||||
category: 'PSEUDONYMIZATION',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Pseudonymisierungskonzept', en: 'Pseudonymization Concept' },
|
||||
description: {
|
||||
de: 'Dokumentiertes Konzept fuer die Pseudonymisierung personenbezogener Daten mit Definition der Verfahren, Zustaendigkeiten und Zuordnungsregeln.',
|
||||
en: 'Documented concept for pseudonymization of personal data with definition of procedures, responsibilities and mapping rules.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
|
||||
{ framework: 'GDPR_ART25', reference: 'Art. 25 Abs. 1' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'CON.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Pseudonymisierungskonzept', 'Verfahrensdokumentation'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['pseudonymization', 'concept', 'documentation'],
|
||||
},
|
||||
|
||||
// INPUT_CONTROL — 1 new
|
||||
{
|
||||
id: 'TOM-IN-05',
|
||||
code: 'TOM-IN-05',
|
||||
category: 'INPUT_CONTROL',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Automatisierte Eingabevalidierung', en: 'Automated Input Validation' },
|
||||
description: {
|
||||
de: 'Technische Validierung aller Benutzereingaben zur Verhinderung von Injection-Angriffen und Sicherstellung der Datenintegritaet.',
|
||||
en: 'Technical validation of all user inputs to prevent injection attacks and ensure data integrity.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.14.2.5' },
|
||||
],
|
||||
applicabilityConditions: [],
|
||||
defaultApplicability: 'REQUIRED',
|
||||
evidenceRequirements: ['Validierungsregeln-Dokumentation', 'Penetrationstest-Berichte'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['input-validation', 'security', 'injection-prevention'],
|
||||
},
|
||||
|
||||
// ORDER_CONTROL — 2 new
|
||||
{
|
||||
id: 'TOM-OR-05',
|
||||
code: 'TOM-OR-05',
|
||||
category: 'ORDER_CONTROL',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Auftragsverarbeiter-Monitoring', en: 'Processor Monitoring' },
|
||||
description: {
|
||||
de: 'Regelmaessige Ueberpruefung und Bewertung der Datenschutz-Massnahmen bei Auftragsverarbeitern gemaess Art. 28 Abs. 3 lit. h DSGVO.',
|
||||
en: 'Regular review and assessment of data protection measures at processors according to Art. 28(3)(h) GDPR.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 28 Abs. 3 lit. h' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.2.1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.hasSubprocessors', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Audit-Berichte der Auftragsverarbeiter', 'Monitoring-Checklisten'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['order-control', 'processor', 'monitoring'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-OR-06',
|
||||
code: 'TOM-OR-06',
|
||||
category: 'ORDER_CONTROL',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Sub-Processor Management', en: 'Sub-Processor Management' },
|
||||
description: {
|
||||
de: 'Dokumentiertes Verfahren zur Genehmigung, Ueberwachung und Dokumentation von Unterauftragsverarbeitern.',
|
||||
en: 'Documented process for approval, monitoring and documentation of sub-processors.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 28 Abs. 2, 4' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.1.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.hasSubprocessors', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
{ field: 'architectureProfile.subprocessorCount', operator: 'GREATER_THAN', value: 3, result: 'REQUIRED', priority: 20 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Sub-Processor-Register', 'Genehmigungsverfahren', 'Vertragsdokumentation'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['order-control', 'sub-processor'],
|
||||
},
|
||||
|
||||
// RESILIENCE — 2 new
|
||||
{
|
||||
id: 'TOM-RE-04',
|
||||
code: 'TOM-RE-04',
|
||||
category: 'RESILIENCE',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'DDoS-Abwehr (erweitert)', en: 'DDoS Mitigation (Advanced)' },
|
||||
description: {
|
||||
de: 'Erweiterte DDoS-Schutzmassnahmen inkl. Traffic-Analyse, automatischer Mitigation und Incident-Response-Integration.',
|
||||
en: 'Advanced DDoS protection measures including traffic analysis, automatic mitigation and incident response integration.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.1.1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'EQUALS', value: 'VERY_HIGH', result: 'REQUIRED', priority: 25 },
|
||||
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['PUBLIC_CLOUD', 'HYBRID'], result: 'RECOMMENDED', priority: 15 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['DDoS-Schutzkonzept (erweitert)', 'Mitigation-Berichte', 'Incident-Playbooks'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'HIGH',
|
||||
complexity: 'HIGH',
|
||||
tags: ['resilience', 'ddos', 'advanced'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-RE-05',
|
||||
code: 'TOM-RE-05',
|
||||
category: 'RESILIENCE',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Kapazitaetsplanung', en: 'Capacity Planning' },
|
||||
description: {
|
||||
de: 'Systematische Planung und Ueberwachung von IT-Kapazitaeten zur Sicherstellung der Systemverfuegbarkeit bei wachsender Nutzung.',
|
||||
en: 'Systematic planning and monitoring of IT capacities to ensure system availability with growing usage.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.1.3' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'dataProfile.dataVolume', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 20 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Kapazitaetsplan', 'Trend-Analysen', 'Skalierungskonzept'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['resilience', 'capacity', 'planning'],
|
||||
},
|
||||
|
||||
// RECOVERY — 2 new
|
||||
{
|
||||
id: 'TOM-RC-04',
|
||||
code: 'TOM-RC-04',
|
||||
category: 'RECOVERY',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Georedundantes Backup', en: 'Geo-Redundant Backup' },
|
||||
description: {
|
||||
de: 'Speicherung von Backup-Kopien an geografisch getrennten Standorten zum Schutz vor standortbezogenen Katastrophen.',
|
||||
en: 'Storage of backup copies at geographically separated locations to protect against site-specific disasters.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. c' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.3.1' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'CON.3' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 25 },
|
||||
{ field: 'riskProfile.ciaAssessment.availability', operator: 'GREATER_THAN', value: 3, result: 'REQUIRED', priority: 20 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Georedundanz-Konzept', 'Backup-Standort-Dokumentation', 'Wiederherstellungstests'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'HIGH',
|
||||
tags: ['recovery', 'backup', 'geo-redundancy'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-RC-05',
|
||||
code: 'TOM-RC-05',
|
||||
category: 'RECOVERY',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Notfallwiederherstellungs-Tests', en: 'Disaster Recovery Testing' },
|
||||
description: {
|
||||
de: 'Regelmaessige Durchfuehrung und Dokumentation von Notfallwiederherstellungstests zur Validierung der RTO/RPO-Ziele.',
|
||||
en: 'Regular execution and documentation of disaster recovery tests to validate RTO/RPO targets.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. c, d' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.17.1.3' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'securityProfile.hasDRPlan', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['DR-Testberichte', 'RTO/RPO-Messungen', 'Verbesserungsmassnahmen'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['recovery', 'dr-testing', 'rto', 'rpo'],
|
||||
},
|
||||
|
||||
// SEPARATION — 2 new
|
||||
{
|
||||
id: 'TOM-SE-05',
|
||||
code: 'TOM-SE-05',
|
||||
category: 'SEPARATION',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Netzwerksegmentierung', en: 'Network Segmentation' },
|
||||
description: {
|
||||
de: 'Aufteilung des Netzwerks in separate Sicherheitszonen mit kontrollierten Uebergaengen zur Begrenzung der Ausbreitung von Sicherheitsvorfaellen.',
|
||||
en: 'Division of the network into separate security zones with controlled transitions to limit the spread of security incidents.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.1.3' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'NET.1.1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['ON_PREMISE', 'PRIVATE_CLOUD', 'HYBRID'], result: 'REQUIRED', priority: 20 },
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Netzwerkplan', 'Firewall-Regeln', 'Segmentierungskonzept'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'HIGH',
|
||||
tags: ['separation', 'network', 'segmentation'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-SE-06',
|
||||
code: 'TOM-SE-06',
|
||||
category: 'SEPARATION',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Mandantenisolierung in Cloud', en: 'Tenant Isolation in Cloud' },
|
||||
description: {
|
||||
de: 'Technische Sicherstellung der vollstaendigen Datentrennung zwischen verschiedenen Mandanten in Multi-Tenant-Cloud-Umgebungen.',
|
||||
en: 'Technical assurance of complete data separation between different tenants in multi-tenant cloud environments.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.1.3' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.multiTenancy', operator: 'EQUALS', value: 'MULTI_TENANT', result: 'REQUIRED', priority: 30 },
|
||||
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['PUBLIC_CLOUD', 'HYBRID'], result: 'RECOMMENDED', priority: 15 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['Mandantentrennungskonzept', 'Isolierungstests', 'Cloud-Security-Assessment'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'CRITICAL',
|
||||
complexity: 'HIGH',
|
||||
tags: ['separation', 'multi-tenant', 'cloud'],
|
||||
},
|
||||
|
||||
// ACCESS_CONTROL — 1 new
|
||||
{
|
||||
id: 'TOM-AC-06',
|
||||
code: 'TOM-AC-06',
|
||||
category: 'ACCESS_CONTROL',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Besuchermanagement (erweitert)', en: 'Visitor Management (Extended)' },
|
||||
description: {
|
||||
de: 'Erweitertes Besuchermanagement mit Voranmeldung, Identitaetspruefung, Begleitpflicht und zeitlich begrenztem Zugang zu sicherheitsrelevanten Bereichen.',
|
||||
en: 'Extended visitor management with pre-registration, identity verification, escort requirement and time-limited access to security-relevant areas.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.7.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 20 },
|
||||
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'RECOMMENDED', priority: 15 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['Besuchermanagement-Richtlinie', 'Besucherprotokolle', 'Zonenkonzept'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'LOW',
|
||||
tags: ['physical-security', 'visitors', 'extended'],
|
||||
},
|
||||
|
||||
// ADMISSION_CONTROL — 1 new
|
||||
{
|
||||
id: 'TOM-ADM-06',
|
||||
code: 'TOM-ADM-06',
|
||||
category: 'ADMISSION_CONTROL',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Endpoint Detection & Response (EDR)', en: 'Endpoint Detection & Response (EDR)' },
|
||||
description: {
|
||||
de: 'Einsatz von EDR-Loesungen zur Erkennung und Abwehr von Bedrohungen auf Endgeraeten in Echtzeit.',
|
||||
en: 'Deployment of EDR solutions for real-time threat detection and response on endpoints.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.2.1' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'OPS.1.1.4' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 25 },
|
||||
{ field: 'companyProfile.size', operator: 'IN', value: ['LARGE', 'ENTERPRISE'], result: 'RECOMMENDED', priority: 10 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['EDR-Konfiguration', 'Bedrohungsberichte', 'Incident-Response-Statistiken'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'HIGH',
|
||||
complexity: 'HIGH',
|
||||
tags: ['endpoint', 'edr', 'threat-detection'],
|
||||
},
|
||||
|
||||
// ACCESS_AUTHORIZATION — 2 new
|
||||
{
|
||||
id: 'TOM-AZ-06',
|
||||
code: 'TOM-AZ-06',
|
||||
category: 'ACCESS_AUTHORIZATION',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'API-Zugriffskontrolle', en: 'API Access Control' },
|
||||
description: {
|
||||
de: 'Implementierung von Authentifizierungs- und Autorisierungsmechanismen fuer APIs (OAuth 2.0, API-Keys, Rate Limiting).',
|
||||
en: 'Implementation of authentication and authorization mechanisms for APIs (OAuth 2.0, API keys, rate limiting).',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.9.4.1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['PUBLIC_CLOUD', 'HYBRID'], result: 'REQUIRED', priority: 20 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['API-Security-Konzept', 'OAuth-Konfiguration', 'Rate-Limiting-Regeln'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['authorization', 'api', 'oauth'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-AZ-07',
|
||||
code: 'TOM-AZ-07',
|
||||
category: 'ACCESS_AUTHORIZATION',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Regelmaessiger Berechtigungsreview', en: 'Regular Permission Review' },
|
||||
description: {
|
||||
de: 'Systematische Ueberpruefung und Bereinigung von Zugriffsberechtigungen in regelmaessigen Abstaenden durch die jeweiligen Fachverantwortlichen.',
|
||||
en: 'Systematic review and cleanup of access permissions at regular intervals by the respective department heads.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.9.2.5' },
|
||||
],
|
||||
applicabilityConditions: [],
|
||||
defaultApplicability: 'REQUIRED',
|
||||
evidenceRequirements: ['Review-Protokolle', 'Berechtigungsaenderungslog', 'Freigabedokumentation'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'LOW',
|
||||
tags: ['authorization', 'review', 'permissions'],
|
||||
},
|
||||
|
||||
// TRANSFER_CONTROL — 2 new
|
||||
{
|
||||
id: 'TOM-TR-06',
|
||||
code: 'TOM-TR-06',
|
||||
category: 'TRANSFER_CONTROL',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'E-Mail-Verschluesselung (erweitert)', en: 'Email Encryption (Extended)' },
|
||||
description: {
|
||||
de: 'Erweiterte E-Mail-Verschluesselung mit automatischer Erkennung sensibler Inhalte und erzwungener Gateway-Verschluesselung.',
|
||||
en: 'Extended email encryption with automatic detection of sensitive content and enforced gateway encryption.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.2.3' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'RECOMMENDED', priority: 15 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['E-Mail-Verschluesselungs-Policy', 'Gateway-Konfiguration', 'DLP-Regeln'],
|
||||
reviewFrequency: 'SEMI_ANNUAL',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['transfer', 'email', 'encryption'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-TR-07',
|
||||
code: 'TOM-TR-07',
|
||||
category: 'TRANSFER_CONTROL',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Drittstaat-Transferbewertung', en: 'Third Country Transfer Assessment' },
|
||||
description: {
|
||||
de: 'Dokumentierte Bewertung und Absicherung von Datenuebermittlungen in Drittstaaten gemaess Art. 44-49 DSGVO (Standardvertragsklauseln, TIA).',
|
||||
en: 'Documented assessment and safeguarding of data transfers to third countries according to Art. 44-49 GDPR (Standard Contractual Clauses, TIA).',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 44-49' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.1.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'dataProfile.thirdCountryTransfers', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 30 },
|
||||
{ field: 'architectureProfile.hostingLocation', operator: 'IN', value: ['THIRD_COUNTRY_ADEQUATE', 'THIRD_COUNTRY'], result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['Transfer Impact Assessment', 'Standardvertragsklauseln', 'Angemessenheitsbeschluss-Pruefung'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'CRITICAL',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['transfer', 'third-country', 'schrems-ii'],
|
||||
},
|
||||
|
||||
// AVAILABILITY — 2 new
|
||||
{
|
||||
id: 'TOM-AV-06',
|
||||
code: 'TOM-AV-06',
|
||||
category: 'AVAILABILITY',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Monitoring und Alerting', en: 'Monitoring and Alerting' },
|
||||
description: {
|
||||
de: 'Implementierung einer umfassenden Ueberwachung aller IT-Systeme mit automatischen Benachrichtigungen bei Stoerungen oder Schwellenwert-Ueberschreitungen.',
|
||||
en: 'Implementation of comprehensive monitoring of all IT systems with automatic notifications for disruptions or threshold violations.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.4.1' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'OPS.1.1.2' },
|
||||
],
|
||||
applicabilityConditions: [],
|
||||
defaultApplicability: 'REQUIRED',
|
||||
evidenceRequirements: ['Monitoring-Konzept', 'Alerting-Konfiguration', 'Eskalationsmatrix'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['availability', 'monitoring', 'alerting'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-AV-07',
|
||||
code: 'TOM-AV-07',
|
||||
category: 'AVAILABILITY',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Service Level Management', en: 'Service Level Management' },
|
||||
description: {
|
||||
de: 'Definition und Ueberwachung von Service Level Agreements (SLAs) fuer alle kritischen IT-Services mit klaren Verfuegbarkeitszielen.',
|
||||
en: 'Definition and monitoring of Service Level Agreements (SLAs) for all critical IT services with clear availability targets.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.2.1' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'companyProfile.size', operator: 'IN', value: ['MEDIUM', 'LARGE', 'ENTERPRISE'], result: 'RECOMMENDED', priority: 10 },
|
||||
{ field: 'architectureProfile.hasSubprocessors', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 20 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['SLA-Dokumentation', 'Verfuegbarkeitsberichte', 'Eskalationsverfahren'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'LOW',
|
||||
tags: ['availability', 'sla', 'service-management'],
|
||||
},
|
||||
|
||||
// SEPARATION — 1 more new (TOM-DL-05)
|
||||
{
|
||||
id: 'TOM-DL-05',
|
||||
code: 'TOM-DL-05',
|
||||
category: 'SEPARATION',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Datenloesch-Audit', en: 'Data Deletion Audit' },
|
||||
description: {
|
||||
de: 'Regelmaessige Ueberpruefung der Wirksamkeit und Vollstaendigkeit von Datenloeschvorgaengen durch unabhaengige Stellen.',
|
||||
en: 'Regular review of the effectiveness and completeness of data deletion processes by independent parties.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 5 Abs. 1 lit. e' },
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 17' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.8.3.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
|
||||
],
|
||||
defaultApplicability: 'RECOMMENDED',
|
||||
evidenceRequirements: ['Audit-Berichte', 'Loeschprotokolle', 'Stichproben-Ergebnisse'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['separation', 'deletion', 'audit'],
|
||||
},
|
||||
|
||||
// REVIEW — 3 new
|
||||
{
|
||||
id: 'TOM-RV-09',
|
||||
code: 'TOM-RV-09',
|
||||
category: 'REVIEW',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Datenschutz-Audit-Programm', en: 'Data Protection Audit Program' },
|
||||
description: {
|
||||
de: 'Systematisches Programm zur regelmaessigen internen Ueberpruefung aller Datenschutzmassnahmen mit dokumentierten Ergebnissen und Massnahmenverfolgung.',
|
||||
en: 'Systematic program for regular internal review of all data protection measures with documented results and action tracking.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.18.2.1' },
|
||||
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'DER.3.1' },
|
||||
],
|
||||
applicabilityConditions: [],
|
||||
defaultApplicability: 'REQUIRED',
|
||||
evidenceRequirements: ['Audit-Programm', 'Audit-Berichte', 'Massnahmenplan'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'MEDIUM',
|
||||
tags: ['review', 'audit', 'data-protection'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-RV-10',
|
||||
code: 'TOM-RV-10',
|
||||
category: 'REVIEW',
|
||||
type: 'TECHNICAL',
|
||||
name: { de: 'Automatisierte Compliance-Pruefung', en: 'Automated Compliance Checking' },
|
||||
description: {
|
||||
de: 'Einsatz automatisierter Tools zur kontinuierlichen Ueberpruefung der Einhaltung von Sicherheits- und Datenschutzrichtlinien.',
|
||||
en: 'Use of automated tools for continuous monitoring of compliance with security and data protection policies.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.18.2.2' },
|
||||
],
|
||||
applicabilityConditions: [
|
||||
{ field: 'companyProfile.size', operator: 'IN', value: ['MEDIUM', 'LARGE', 'ENTERPRISE'], result: 'RECOMMENDED', priority: 10 },
|
||||
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'RECOMMENDED', priority: 15 },
|
||||
],
|
||||
defaultApplicability: 'OPTIONAL',
|
||||
evidenceRequirements: ['Tool-Konfiguration', 'Compliance-Dashboard', 'Automatisierte Berichte'],
|
||||
reviewFrequency: 'QUARTERLY',
|
||||
priority: 'MEDIUM',
|
||||
complexity: 'HIGH',
|
||||
tags: ['review', 'automation', 'compliance'],
|
||||
},
|
||||
{
|
||||
id: 'TOM-RV-11',
|
||||
code: 'TOM-RV-11',
|
||||
category: 'REVIEW',
|
||||
type: 'ORGANIZATIONAL',
|
||||
name: { de: 'Management Review (Art. 32 Abs. 1 lit. d)', en: 'Management Review (Art. 32(1)(d))' },
|
||||
description: {
|
||||
de: 'Regelmaessige Ueberpruefung der Wirksamkeit aller technischen und organisatorischen Massnahmen durch die Geschaeftsfuehrung mit dokumentierten Ergebnissen.',
|
||||
en: 'Regular review of the effectiveness of all technical and organizational measures by management with documented results.',
|
||||
},
|
||||
mappings: [
|
||||
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
|
||||
{ framework: 'ISO27001_ANNEX_A', reference: 'A.18.2.1' },
|
||||
],
|
||||
applicabilityConditions: [],
|
||||
defaultApplicability: 'REQUIRED',
|
||||
evidenceRequirements: ['Management-Review-Protokolle', 'Massnahmenplan', 'Wirksamkeitsbewertung'],
|
||||
reviewFrequency: 'ANNUAL',
|
||||
priority: 'HIGH',
|
||||
complexity: 'LOW',
|
||||
tags: ['review', 'management', 'effectiveness'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -796,16 +796,16 @@ export const SDK_STEPS: SDKStep[] = [
|
||||
},
|
||||
{
|
||||
id: 'vendor-compliance',
|
||||
seq: 4200,
|
||||
seq: 2500,
|
||||
phase: 2,
|
||||
package: 'betrieb',
|
||||
order: 3,
|
||||
package: 'dokumentation',
|
||||
order: 6,
|
||||
name: 'Vendor Compliance',
|
||||
nameShort: 'Vendor',
|
||||
description: 'Dienstleister-Management',
|
||||
url: '/sdk/vendor-compliance',
|
||||
checkpointId: 'CP-VEND',
|
||||
prerequisiteSteps: ['escalations'],
|
||||
prerequisiteSteps: ['vvt'],
|
||||
isOptional: false,
|
||||
},
|
||||
{
|
||||
@@ -920,6 +920,20 @@ export const SDK_STEPS: SDKStep[] = [
|
||||
prerequisiteSteps: [],
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
id: 'atomic-controls',
|
||||
seq: 4925,
|
||||
phase: 2,
|
||||
package: 'betrieb',
|
||||
order: 11.5,
|
||||
name: 'Atomare Controls',
|
||||
nameShort: 'Atomar',
|
||||
description: 'Deduplizierte atomare Controls mit Herkunftsnachweis',
|
||||
url: '/sdk/atomic-controls',
|
||||
checkpointId: 'CP-ATOM',
|
||||
prerequisiteSteps: [],
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
id: 'control-provenance',
|
||||
seq: 4950,
|
||||
@@ -1939,6 +1953,7 @@ export type LicenseType =
|
||||
* Template types available for document generation
|
||||
*/
|
||||
export type TemplateType =
|
||||
// Legal / Vertragsvorlagen
|
||||
| 'privacy_policy'
|
||||
| 'terms_of_service'
|
||||
| 'agb'
|
||||
@@ -1956,6 +1971,55 @@ export type TemplateType =
|
||||
| 'copyright_policy'
|
||||
| 'clause'
|
||||
| 'dsfa'
|
||||
// Sicherheitskonzepte (Migration 051)
|
||||
| 'it_security_concept'
|
||||
| 'data_protection_concept'
|
||||
| 'backup_recovery_concept'
|
||||
| 'logging_concept'
|
||||
| 'incident_response_plan'
|
||||
| 'access_control_concept'
|
||||
| 'risk_management_concept'
|
||||
// CRA Cybersecurity (Migration 056)
|
||||
| 'cybersecurity_policy'
|
||||
// IT-Sicherheit Policies (Migration 071)
|
||||
| 'information_security_policy'
|
||||
| 'access_control_policy'
|
||||
| 'password_policy'
|
||||
| 'encryption_policy'
|
||||
| 'logging_policy'
|
||||
| 'backup_policy'
|
||||
| 'incident_response_policy'
|
||||
| 'change_management_policy'
|
||||
| 'patch_management_policy'
|
||||
| 'asset_management_policy'
|
||||
| 'cloud_security_policy'
|
||||
| 'devsecops_policy'
|
||||
| 'secrets_management_policy'
|
||||
| 'vulnerability_management_policy'
|
||||
// Daten-Policies (Migration 072)
|
||||
| 'data_protection_policy'
|
||||
| 'data_classification_policy'
|
||||
| 'data_retention_policy'
|
||||
| 'data_transfer_policy'
|
||||
| 'privacy_incident_policy'
|
||||
// Personal-Policies (Migration 072)
|
||||
| 'employee_security_policy'
|
||||
| 'security_awareness_policy'
|
||||
| 'remote_work_policy'
|
||||
| 'offboarding_policy'
|
||||
// Lieferanten-Policies (Migration 072)
|
||||
| 'vendor_risk_management_policy'
|
||||
| 'third_party_security_policy'
|
||||
| 'supplier_security_policy'
|
||||
// BCM/Notfall (Migration 072)
|
||||
| 'business_continuity_policy'
|
||||
| 'disaster_recovery_policy'
|
||||
| 'crisis_management_policy'
|
||||
// Modul-Dokumente (Migration 073)
|
||||
| 'vvt_register'
|
||||
| 'tom_documentation'
|
||||
| 'loeschkonzept'
|
||||
| 'pflichtenregister'
|
||||
|
||||
/**
|
||||
* Jurisdiction codes for legal documents
|
||||
@@ -2190,6 +2254,7 @@ export const DEFAULT_PLACEHOLDERS: Record<string, string> = {
|
||||
* Template type labels for display
|
||||
*/
|
||||
export const TEMPLATE_TYPE_LABELS: Record<TemplateType, string> = {
|
||||
// Legal / Vertragsvorlagen
|
||||
privacy_policy: 'Datenschutzerklärung',
|
||||
terms_of_service: 'Nutzungsbedingungen',
|
||||
agb: 'Allgemeine Geschäftsbedingungen',
|
||||
@@ -2207,6 +2272,54 @@ export const TEMPLATE_TYPE_LABELS: Record<TemplateType, string> = {
|
||||
copyright_policy: 'Urheberrechtsrichtlinie',
|
||||
clause: 'Vertragsklausel',
|
||||
dsfa: 'Datenschutz-Folgenabschätzung',
|
||||
// Sicherheitskonzepte
|
||||
it_security_concept: 'IT-Sicherheitskonzept',
|
||||
data_protection_concept: 'Datenschutzkonzept',
|
||||
backup_recovery_concept: 'Backup- und Recovery-Konzept',
|
||||
logging_concept: 'Logging-Konzept',
|
||||
incident_response_plan: 'Incident-Response-Plan',
|
||||
access_control_concept: 'Zugriffskonzept',
|
||||
risk_management_concept: 'Risikomanagement-Konzept',
|
||||
cybersecurity_policy: 'Cybersecurity-Richtlinie (CRA)',
|
||||
// IT-Sicherheit Policies
|
||||
information_security_policy: 'Informationssicherheitsrichtlinie',
|
||||
access_control_policy: 'Zugriffskontrollrichtlinie',
|
||||
password_policy: 'Passwortrichtlinie',
|
||||
encryption_policy: 'Verschlüsselungsrichtlinie',
|
||||
logging_policy: 'Protokollierungsrichtlinie',
|
||||
backup_policy: 'Datensicherungsrichtlinie',
|
||||
incident_response_policy: 'Incident-Response-Richtlinie',
|
||||
change_management_policy: 'Change-Management-Richtlinie',
|
||||
patch_management_policy: 'Patch-Management-Richtlinie',
|
||||
asset_management_policy: 'Asset-Management-Richtlinie',
|
||||
cloud_security_policy: 'Cloud-Security-Richtlinie',
|
||||
devsecops_policy: 'DevSecOps-Richtlinie',
|
||||
secrets_management_policy: 'Secrets-Management-Richtlinie',
|
||||
vulnerability_management_policy: 'Schwachstellenmanagement-Richtlinie',
|
||||
// Daten-Policies
|
||||
data_protection_policy: 'Datenschutzrichtlinie',
|
||||
data_classification_policy: 'Datenklassifizierungsrichtlinie',
|
||||
data_retention_policy: 'Aufbewahrungsrichtlinie',
|
||||
data_transfer_policy: 'Datenübermittlungsrichtlinie',
|
||||
privacy_incident_policy: 'Datenschutzvorfall-Richtlinie',
|
||||
// Personal-Policies
|
||||
employee_security_policy: 'Mitarbeiter-Sicherheitsrichtlinie',
|
||||
security_awareness_policy: 'Security-Awareness-Richtlinie',
|
||||
remote_work_policy: 'Remote-Work-Richtlinie',
|
||||
offboarding_policy: 'Offboarding-Richtlinie',
|
||||
// Lieferanten-Policies
|
||||
vendor_risk_management_policy: 'Lieferanten-Risikomanagement',
|
||||
third_party_security_policy: 'Drittanbieter-Sicherheitsrichtlinie',
|
||||
supplier_security_policy: 'Lieferanten-Sicherheitsanforderungen',
|
||||
// BCM/Notfall
|
||||
business_continuity_policy: 'Business-Continuity-Richtlinie',
|
||||
disaster_recovery_policy: 'Disaster-Recovery-Richtlinie',
|
||||
crisis_management_policy: 'Krisenmanagement-Richtlinie',
|
||||
// Modul-Dokumente
|
||||
vvt_register: 'Verarbeitungsverzeichnis (Art. 30)',
|
||||
tom_documentation: 'TOM-Dokumentation (Art. 32)',
|
||||
loeschkonzept: 'Löschkonzept (Art. 5/17)',
|
||||
pflichtenregister: 'Pflichtenregister',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,16 +15,9 @@ import {
|
||||
VendorComplianceAction,
|
||||
VendorComplianceContextValue,
|
||||
ProcessingActivity,
|
||||
Vendor,
|
||||
ContractDocument,
|
||||
Finding,
|
||||
Control,
|
||||
ControlInstance,
|
||||
RiskAssessment,
|
||||
VendorStatistics,
|
||||
ComplianceStatistics,
|
||||
RiskOverview,
|
||||
ExportFormat,
|
||||
VendorStatus,
|
||||
VendorRole,
|
||||
RiskLevel,
|
||||
@@ -475,24 +468,6 @@ export function VendorComplianceProvider({
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const updateProcessingActivity = useCallback(
|
||||
async (id: string, data: Partial<ProcessingActivity>): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/processing-activities/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Aktualisieren der Verarbeitungstätigkeit')
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_PROCESSING_ACTIVITY', payload: { id, data } })
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const deleteProcessingActivity = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/processing-activities/${id}`, {
|
||||
@@ -537,49 +512,6 @@ export function VendorComplianceProvider({
|
||||
// VENDOR ACTIONS
|
||||
// ==========================================
|
||||
|
||||
const createVendor = useCallback(
|
||||
async (
|
||||
data: Omit<Vendor, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<Vendor> => {
|
||||
const response = await fetch(`${apiBase}/vendors`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Erstellen des Vendors')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const vendor = result.data
|
||||
|
||||
dispatch({ type: 'ADD_VENDOR', payload: vendor })
|
||||
|
||||
return vendor
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const updateVendor = useCallback(
|
||||
async (id: string, data: Partial<Vendor>): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/vendors/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Aktualisieren des Vendors')
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_VENDOR', payload: { id, data } })
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const deleteVendor = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/vendors/${id}`, {
|
||||
@@ -600,67 +532,6 @@ export function VendorComplianceProvider({
|
||||
// CONTRACT ACTIONS
|
||||
// ==========================================
|
||||
|
||||
const uploadContract = useCallback(
|
||||
async (
|
||||
vendorId: string,
|
||||
file: File,
|
||||
metadata: Partial<ContractDocument>
|
||||
): Promise<ContractDocument> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('vendorId', vendorId)
|
||||
formData.append('metadata', JSON.stringify(metadata))
|
||||
|
||||
const response = await fetch(`${apiBase}/contracts`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Hochladen des Vertrags')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const contract = result.data
|
||||
|
||||
dispatch({ type: 'ADD_CONTRACT', payload: contract })
|
||||
|
||||
// Update vendor's contracts list
|
||||
const vendor = state.vendors.find((v) => v.id === vendorId)
|
||||
if (vendor) {
|
||||
dispatch({
|
||||
type: 'UPDATE_VENDOR',
|
||||
payload: {
|
||||
id: vendorId,
|
||||
data: { contracts: [...vendor.contracts, contract.id] },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return contract
|
||||
},
|
||||
[apiBase, state.vendors]
|
||||
)
|
||||
|
||||
const updateContract = useCallback(
|
||||
async (id: string, data: Partial<ContractDocument>): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/contracts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Aktualisieren des Vertrags')
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_CONTRACT', payload: { id, data } })
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const deleteContract = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
const contract = state.contracts.find((c) => c.id === id)
|
||||
@@ -736,125 +607,6 @@ export function VendorComplianceProvider({
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
// ==========================================
|
||||
// FINDINGS ACTIONS
|
||||
// ==========================================
|
||||
|
||||
const updateFinding = useCallback(
|
||||
async (id: string, data: Partial<Finding>): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/findings/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Aktualisieren des Findings')
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_FINDING', payload: { id, data } })
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const resolveFinding = useCallback(
|
||||
async (id: string, resolution: string): Promise<void> => {
|
||||
await updateFinding(id, {
|
||||
status: 'RESOLVED',
|
||||
resolution,
|
||||
resolvedAt: new Date(),
|
||||
})
|
||||
},
|
||||
[updateFinding]
|
||||
)
|
||||
|
||||
// ==========================================
|
||||
// CONTROL ACTIONS
|
||||
// ==========================================
|
||||
|
||||
const updateControlInstance = useCallback(
|
||||
async (id: string, data: Partial<ControlInstance>): Promise<void> => {
|
||||
const response = await fetch(`${apiBase}/control-instances/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Aktualisieren des Control-Status')
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_CONTROL_INSTANCE', payload: { id, data } })
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
// ==========================================
|
||||
// EXPORT ACTIONS
|
||||
// ==========================================
|
||||
|
||||
const exportVVT = useCallback(
|
||||
async (format: ExportFormat, activityIds?: string[]): Promise<string> => {
|
||||
const params = new URLSearchParams({ format })
|
||||
if (activityIds && activityIds.length > 0) {
|
||||
params.append('activityIds', activityIds.join(','))
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}/export/vvt?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Exportieren des VVT')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return url
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const exportVendorAuditPack = useCallback(
|
||||
async (vendorId: string, format: ExportFormat): Promise<string> => {
|
||||
const params = new URLSearchParams({ format, vendorId })
|
||||
|
||||
const response = await fetch(`${apiBase}/export/vendor-audit?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Exportieren des Vendor Audit Packs')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return url
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
const exportRoPA = useCallback(
|
||||
async (format: ExportFormat): Promise<string> => {
|
||||
const params = new URLSearchParams({ format })
|
||||
|
||||
const response = await fetch(`${apiBase}/export/ropa?${params}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Fehler beim Exportieren des RoPA')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return url
|
||||
},
|
||||
[apiBase]
|
||||
)
|
||||
|
||||
// ==========================================
|
||||
// INITIALIZATION
|
||||
// ==========================================
|
||||
@@ -877,23 +629,11 @@ export function VendorComplianceProvider({
|
||||
vendorStats,
|
||||
complianceStats,
|
||||
riskOverview,
|
||||
createProcessingActivity,
|
||||
updateProcessingActivity,
|
||||
deleteProcessingActivity,
|
||||
duplicateProcessingActivity,
|
||||
createVendor,
|
||||
updateVendor,
|
||||
deleteVendor,
|
||||
uploadContract,
|
||||
updateContract,
|
||||
deleteContract,
|
||||
startContractReview,
|
||||
updateFinding,
|
||||
resolveFinding,
|
||||
updateControlInstance,
|
||||
exportVVT,
|
||||
exportVendorAuditPack,
|
||||
exportRoPA,
|
||||
loadData,
|
||||
refresh,
|
||||
}),
|
||||
@@ -902,23 +642,11 @@ export function VendorComplianceProvider({
|
||||
vendorStats,
|
||||
complianceStats,
|
||||
riskOverview,
|
||||
createProcessingActivity,
|
||||
updateProcessingActivity,
|
||||
deleteProcessingActivity,
|
||||
duplicateProcessingActivity,
|
||||
createVendor,
|
||||
updateVendor,
|
||||
deleteVendor,
|
||||
uploadContract,
|
||||
updateContract,
|
||||
deleteContract,
|
||||
startContractReview,
|
||||
updateFinding,
|
||||
resolveFinding,
|
||||
updateControlInstance,
|
||||
exportVVT,
|
||||
exportVendorAuditPack,
|
||||
exportRoPA,
|
||||
loadData,
|
||||
refresh,
|
||||
]
|
||||
@@ -947,64 +675,3 @@ export function useVendorCompliance(): VendorComplianceContextValue {
|
||||
return context
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SELECTORS
|
||||
// ==========================================
|
||||
|
||||
export function useVendor(vendorId: string | null) {
|
||||
const { vendors } = useVendorCompliance()
|
||||
return useMemo(
|
||||
() => vendors.find((v) => v.id === vendorId) ?? null,
|
||||
[vendors, vendorId]
|
||||
)
|
||||
}
|
||||
|
||||
export function useProcessingActivity(activityId: string | null) {
|
||||
const { processingActivities } = useVendorCompliance()
|
||||
return useMemo(
|
||||
() => processingActivities.find((a) => a.id === activityId) ?? null,
|
||||
[processingActivities, activityId]
|
||||
)
|
||||
}
|
||||
|
||||
export function useVendorContracts(vendorId: string | null) {
|
||||
const { contracts } = useVendorCompliance()
|
||||
return useMemo(
|
||||
() => contracts.filter((c) => c.vendorId === vendorId),
|
||||
[contracts, vendorId]
|
||||
)
|
||||
}
|
||||
|
||||
export function useVendorFindings(vendorId: string | null) {
|
||||
const { findings } = useVendorCompliance()
|
||||
return useMemo(
|
||||
() => findings.filter((f) => f.vendorId === vendorId),
|
||||
[findings, vendorId]
|
||||
)
|
||||
}
|
||||
|
||||
export function useContractFindings(contractId: string | null) {
|
||||
const { findings } = useVendorCompliance()
|
||||
return useMemo(
|
||||
() => findings.filter((f) => f.contractId === contractId),
|
||||
[findings, contractId]
|
||||
)
|
||||
}
|
||||
|
||||
export function useControlInstancesForEntity(
|
||||
entityType: 'VENDOR' | 'PROCESSING_ACTIVITY',
|
||||
entityId: string | null
|
||||
) {
|
||||
const { controlInstances, controls } = useVendorCompliance()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!entityId) return []
|
||||
|
||||
return controlInstances
|
||||
.filter((ci) => ci.entityType === entityType && ci.entityId === entityId)
|
||||
.map((ci) => ({
|
||||
...ci,
|
||||
control: controls.find((c) => c.id === ci.controlId),
|
||||
}))
|
||||
}, [controlInstances, controls, entityType, entityId])
|
||||
}
|
||||
|
||||
@@ -21,12 +21,6 @@ export * from './types'
|
||||
export {
|
||||
VendorComplianceProvider,
|
||||
useVendorCompliance,
|
||||
useVendor,
|
||||
useProcessingActivity,
|
||||
useVendorContracts,
|
||||
useVendorFindings,
|
||||
useContractFindings,
|
||||
useControlInstancesForEntity,
|
||||
} from './context'
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -828,34 +828,16 @@ export interface VendorComplianceContextValue extends VendorComplianceState {
|
||||
riskOverview: RiskOverview
|
||||
|
||||
// Actions - Processing Activities
|
||||
createProcessingActivity: (data: Omit<ProcessingActivity, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>) => Promise<ProcessingActivity>
|
||||
updateProcessingActivity: (id: string, data: Partial<ProcessingActivity>) => Promise<void>
|
||||
deleteProcessingActivity: (id: string) => Promise<void>
|
||||
duplicateProcessingActivity: (id: string) => Promise<ProcessingActivity>
|
||||
|
||||
// Actions - Vendors
|
||||
createVendor: (data: Omit<Vendor, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>) => Promise<Vendor>
|
||||
updateVendor: (id: string, data: Partial<Vendor>) => Promise<void>
|
||||
deleteVendor: (id: string) => Promise<void>
|
||||
|
||||
// Actions - Contracts
|
||||
uploadContract: (vendorId: string, file: File, metadata: Partial<ContractDocument>) => Promise<ContractDocument>
|
||||
updateContract: (id: string, data: Partial<ContractDocument>) => Promise<void>
|
||||
deleteContract: (id: string) => Promise<void>
|
||||
startContractReview: (contractId: string) => Promise<void>
|
||||
|
||||
// Actions - Findings
|
||||
updateFinding: (id: string, data: Partial<Finding>) => Promise<void>
|
||||
resolveFinding: (id: string, resolution: string) => Promise<void>
|
||||
|
||||
// Actions - Controls
|
||||
updateControlInstance: (id: string, data: Partial<ControlInstance>) => Promise<void>
|
||||
|
||||
// Actions - Export
|
||||
exportVVT: (format: ExportFormat, activityIds?: string[]) => Promise<string>
|
||||
exportVendorAuditPack: (vendorId: string, format: ExportFormat) => Promise<string>
|
||||
exportRoPA: (format: ExportFormat) => Promise<string>
|
||||
|
||||
// Data Loading
|
||||
loadData: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
@@ -959,15 +941,6 @@ export interface VendorFormData {
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ContractUploadData {
|
||||
vendorId: string
|
||||
documentType: DocumentType
|
||||
version: string
|
||||
effectiveDate?: Date
|
||||
expirationDate?: Date
|
||||
autoRenewal?: boolean
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// HELPER FUNCTIONS
|
||||
// ==========================================
|
||||
|
||||
@@ -103,25 +103,21 @@ export interface VVTActivity {
|
||||
owner: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Processor-Record (Art. 30 Abs. 2)
|
||||
export interface VVTProcessorActivity {
|
||||
id: string
|
||||
vvtId: string
|
||||
controllerReference: string
|
||||
processingCategories: string[]
|
||||
subProcessorChain: SubProcessor[]
|
||||
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string }[]
|
||||
tomDescription: string
|
||||
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
|
||||
}
|
||||
|
||||
export interface SubProcessor {
|
||||
name: string
|
||||
purpose: string
|
||||
country: string
|
||||
isThirdCountry: boolean
|
||||
// Library refs (optional, parallel to freetext)
|
||||
purposeRefs?: string[]
|
||||
legalBasisRefs?: string[]
|
||||
dataSubjectRefs?: string[]
|
||||
dataCategoryRefs?: string[]
|
||||
recipientRefs?: string[]
|
||||
retentionRuleRef?: string
|
||||
transferMechanismRefs?: string[]
|
||||
tomRefs?: string[]
|
||||
linkedLoeschfristenIds?: string[]
|
||||
linkedTomMeasureIds?: string[]
|
||||
sourceTemplateId?: string
|
||||
riskScore?: number
|
||||
art30Completeness?: VVTCompleteness
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -186,6 +182,14 @@ export const ART9_CATEGORIES: string[] = [
|
||||
'CRIMINAL_DATA',
|
||||
]
|
||||
|
||||
export interface VVTCompleteness {
|
||||
score: number
|
||||
missing: string[]
|
||||
warnings: string[]
|
||||
passed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER: Create empty activity
|
||||
// =============================================================================
|
||||
|
||||
53
admin-compliance/migrations/wiki_betrvg_article.sql
Normal file
53
admin-compliance/migrations/wiki_betrvg_article.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Wiki Article: BetrVG & KI — Mitbestimmung bei IT-Systemen
|
||||
-- Kategorie: arbeitsrecht (existiert bereits)
|
||||
-- Ausfuehren auf Production-DB nach Compliance-Refactoring
|
||||
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES
|
||||
('betrvg-ki-mitbestimmung', 'arbeitsrecht',
|
||||
'BetrVG & KI — Mitbestimmung bei IT-Systemen',
|
||||
'Uebersicht der Mitbestimmungsrechte des Betriebsrats bei Einfuehrung von KI- und IT-Systemen gemaess §87 Abs.1 Nr.6 BetrVG. Inkl. BAG-Rechtsprechung und Konflikt-Score.',
|
||||
'# BetrVG & KI — Mitbestimmung bei IT-Systemen
|
||||
|
||||
## Kernregel: §87 Abs.1 Nr.6 BetrVG
|
||||
|
||||
Die **Einfuehrung und Anwendung** von technischen Einrichtungen, die dazu **geeignet** sind, das Verhalten oder die Leistung der Arbeitnehmer zu ueberwachen, beduerfen der **Zustimmung des Betriebsrats**.
|
||||
|
||||
### Wichtig: Eignung genuegt!
|
||||
Das BAG hat klargestellt: Bereits die **objektive Eignung** zur Ueberwachung genuegt — eine tatsaechliche Nutzung zu diesem Zweck ist nicht erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## Leitentscheidungen des BAG
|
||||
|
||||
### Microsoft Office 365 (BAG 1 ABR 20/21, 08.03.2022)
|
||||
Das BAG hat ausdruecklich entschieden, dass Microsoft Office 365 der Mitbestimmung unterliegt.
|
||||
|
||||
### Standardsoftware (BAG 1 ABN 36/18, 23.10.2018)
|
||||
Auch alltaegliche Standardsoftware wie Excel ist mitbestimmungsrelevant. Keine Geringfuegigkeitsschwelle.
|
||||
|
||||
### SAP ERP (BAG 1 ABR 45/11, 25.09.2012)
|
||||
HR-/ERP-Systeme erheben und verknuepfen individualisierbare Verhaltens- und Leistungsdaten.
|
||||
|
||||
### SaaS/Cloud (BAG 1 ABR 68/13, 21.07.2015)
|
||||
Auch bei Ueberwachung ueber Dritt-Systeme bleibt der Betriebsrat zu beteiligen.
|
||||
|
||||
### Belastungsstatistik (BAG 1 ABR 46/15, 25.04.2017)
|
||||
Dauerhafte Kennzahlenueberwachung ist ein schwerwiegender Eingriff in das Persoenlichkeitsrecht.
|
||||
|
||||
---
|
||||
|
||||
## Betriebsrats-Konflikt-Score (SDK)
|
||||
|
||||
Das SDK berechnet automatisch einen Konflikt-Score (0-100):
|
||||
- Beschaeftigtendaten (+10), Ueberwachungseignung (+20), HR-Bezug (+20)
|
||||
- Individualisierbare Logs (+15), Kommunikationsanalyse (+10)
|
||||
- Scoring/Ranking (+10), Vollautomatisiert (+10), Keine BR-Konsultation (+5)
|
||||
|
||||
Eskalation: Score >= 50 ohne BR → E2, Score >= 75 → E3.',
|
||||
'["§87 Abs.1 Nr.6 BetrVG", "§90 BetrVG", "§94 BetrVG", "§95 BetrVG", "Art. 88 DSGVO", "§26 BDSG"]',
|
||||
ARRAY['BetrVG', 'Mitbestimmung', 'Betriebsrat', 'KI', 'Ueberwachung', 'Microsoft 365'],
|
||||
'critical',
|
||||
'["https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/", "https://www.bundesarbeitsgericht.de/entscheidung/1-abn-36-18/"]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, summary = EXCLUDED.summary, updated_at = NOW();
|
||||
157
admin-compliance/migrations/wiki_domain_articles.sql
Normal file
157
admin-compliance/migrations/wiki_domain_articles.sql
Normal file
@@ -0,0 +1,157 @@
|
||||
-- Wiki Articles: Domain-spezifische KI-Compliance
|
||||
-- 4 Artikel fuer die wichtigsten Hochrisiko-Domains
|
||||
|
||||
-- 1. KI im Recruiting
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-recruiting-compliance', 'arbeitsrecht',
|
||||
'KI im Recruiting — AGG, DSGVO Art. 22, AI Act Hochrisiko',
|
||||
'Compliance-Anforderungen bei KI-gestuetzter Personalauswahl: Automatisierte Absagen, Bias-Risiken, Beweislastumkehr.',
|
||||
'# KI im Recruiting — Compliance-Anforderungen
|
||||
|
||||
## AI Act Einstufung
|
||||
KI im Recruiting faellt unter **Annex III Nr. 4 (Employment)** = **High-Risk**.
|
||||
|
||||
## Kritische Punkte
|
||||
|
||||
### Art. 22 DSGVO — Automatisierte Entscheidungen
|
||||
Vollautomatische Absagen ohne menschliche Pruefung sind **grundsaetzlich unzulaessig**.
|
||||
Erlaubt: KI erstellt Vorschlag → Mensch prueft → Mensch entscheidet → Mensch gibt Absage frei.
|
||||
|
||||
### AGG — Diskriminierungsverbot
|
||||
- § 1 AGG: Keine Benachteiligung nach Geschlecht, Alter, Herkunft, Religion, Behinderung
|
||||
- § 22 AGG: **Beweislastumkehr** — Arbeitgeber muss beweisen, dass KEINE Diskriminierung vorliegt
|
||||
- § 15 AGG: Schadensersatz bis 3 Monatsgehaelter pro Fall
|
||||
- Proxy-Merkmale vermeiden: Name→Herkunft, Foto→Alter
|
||||
|
||||
### BetrVG — Mitbestimmung
|
||||
- § 87 Abs. 1 Nr. 6: Betriebsrat muss zustimmen
|
||||
- § 95: Auswahlrichtlinien mitbestimmungspflichtig
|
||||
- BAG 1 ABR 20/21: Gilt auch fuer Standardsoftware
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Human-in-the-Loop (echt, kein Rubber Stamping)
|
||||
2. Regelmaessige Bias-Audits
|
||||
3. DSFA durchfuehren
|
||||
4. Betriebsvereinbarung abschliessen
|
||||
5. Bewerber ueber KI-Nutzung informieren',
|
||||
'["Art. 22 DSGVO", "§ 1 AGG", "§ 22 AGG", "§ 15 AGG", "§ 87 BetrVG", "§ 95 BetrVG", "Annex III Nr. 4 AI Act"]',
|
||||
ARRAY['Recruiting', 'HR', 'AGG', 'Bias', 'Art. 22', 'Beweislastumkehr', 'Betriebsrat'],
|
||||
'critical',
|
||||
'["https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/"]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
-- 2. KI in der Bildung
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-bildung-compliance', 'branchenspezifisch',
|
||||
'KI in der Bildung — Notenvergabe, Pruefungsbewertung, Minderjaehrige',
|
||||
'AI Act Annex III Nr. 3: Hochrisiko bei KI-gestuetzter Bewertung in Bildung und Ausbildung.',
|
||||
'# KI in der Bildung — Compliance-Anforderungen
|
||||
|
||||
## AI Act Einstufung
|
||||
KI in Bildung/Ausbildung faellt unter **Annex III Nr. 3 (Education)** = **High-Risk**.
|
||||
|
||||
## Kritische Szenarien
|
||||
- KI beeinflusst Noten → High-Risk
|
||||
- KI bewertet Pruefungen → High-Risk
|
||||
- KI steuert Zugang zu Bildungsangeboten → High-Risk
|
||||
- Minderjaehrige betroffen → Besonderer Schutz (Art. 24 EU-Grundrechtecharta)
|
||||
|
||||
## BLOCK-Regel
|
||||
**Minderjaehrige betroffen + keine Lehrkraft-Pruefung = UNZULAESSIG**
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Lehrkraft prueft JEDES KI-Ergebnis vor Mitteilung an Schueler
|
||||
2. Chancengleichheit unabhaengig von sozioekonomischem Hintergrund
|
||||
3. Keine Benachteiligung durch Sprache oder Behinderung
|
||||
4. FRIA durchfuehren (Grundrechte-Folgenabschaetzung)
|
||||
5. DSFA bei Verarbeitung von Schuelerdaten
|
||||
|
||||
## Grundrechte
|
||||
- Recht auf Bildung (Art. 14 EU-Charta)
|
||||
- Rechte des Kindes (Art. 24 EU-Charta)
|
||||
- Nicht-Diskriminierung (Art. 21 EU-Charta)',
|
||||
'["Annex III Nr. 3 AI Act", "Art. 14 EU-Grundrechtecharta", "Art. 24 EU-Grundrechtecharta", "Art. 35 DSGVO"]',
|
||||
ARRAY['Bildung', 'Education', 'Noten', 'Pruefung', 'Minderjaehrige', 'Schule'],
|
||||
'critical',
|
||||
'[]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
-- 3. KI im Gesundheitswesen
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-gesundheit-compliance', 'branchenspezifisch',
|
||||
'KI im Gesundheitswesen — MDR, Diagnose, Triage',
|
||||
'AI Act Annex III Nr. 5 + MDR: Hochrisiko bei KI in Diagnose, Behandlung und Triage.',
|
||||
'# KI im Gesundheitswesen — Compliance-Anforderungen
|
||||
|
||||
## Regulatorischer Rahmen
|
||||
- **AI Act Annex III Nr. 5** — Zugang zu wesentlichen Diensten (Gesundheit)
|
||||
- **MDR (EU) 2017/745** — Medizinprodukteverordnung
|
||||
- **DSGVO Art. 9** — Gesundheitsdaten = besondere Kategorie
|
||||
|
||||
## Kritische Szenarien
|
||||
- KI unterstuetzt Diagnosen → High-Risk + DSFA Pflicht
|
||||
- KI priorisiert Patienten (Triage) → Lebenskritisch, hoechste Anforderungen
|
||||
- KI empfiehlt Behandlungen → High-Risk
|
||||
- System ist Medizinprodukt → MDR-Zertifizierung erforderlich
|
||||
|
||||
## BLOCK-Regeln
|
||||
- **Medizinprodukt ohne klinische Validierung = UNZULAESSIG**
|
||||
- MDR Art. 61: Klinische Bewertung ist Pflicht
|
||||
|
||||
## Grundrechte
|
||||
- Menschenwuerde (Art. 1 EU-Charta)
|
||||
- Schutz personenbezogener Daten (Art. 8 EU-Charta)
|
||||
- Patientenautonomie
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Klinische Validierung vor Einsatz
|
||||
2. Human Oversight durch qualifiziertes Fachpersonal
|
||||
3. DSFA fuer Gesundheitsdatenverarbeitung
|
||||
4. Genauigkeitsmetriken definieren und messen
|
||||
5. Incident Reporting bei Fehlfunktionen',
|
||||
'["Annex III Nr. 5 AI Act", "MDR (EU) 2017/745", "Art. 9 DSGVO", "Art. 35 DSGVO"]',
|
||||
ARRAY['Gesundheit', 'Healthcare', 'MDR', 'Diagnose', 'Triage', 'Medizinprodukt'],
|
||||
'critical',
|
||||
'[]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
-- 4. KI in Finanzdienstleistungen
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-finance-compliance', 'branchenspezifisch',
|
||||
'KI in Finanzdienstleistungen — Scoring, DORA, Versicherung',
|
||||
'AI Act Annex III Nr. 5 + DORA + MaRisk: Compliance bei Kredit-Scoring, Algo-Trading, Versicherungspraemien.',
|
||||
'# KI in Finanzdienstleistungen — Compliance-Anforderungen
|
||||
|
||||
## Regulatorischer Rahmen
|
||||
- **AI Act Annex III Nr. 5** — Zugang zu wesentlichen Diensten
|
||||
- **DORA** — Digital Operational Resilience Act
|
||||
- **MaRisk/BAIT** — Bankaufsichtliche Anforderungen
|
||||
- **MiFID II** — Algorithmischer Handel
|
||||
|
||||
## Kritische Szenarien
|
||||
- Kredit-Scoring → High-Risk (Art. 22 DSGVO + Annex III)
|
||||
- Automatisierte Schadenbearbeitung → Art. 22 Risiko
|
||||
- Individuelle Praemienberechnung → Diskriminierungsrisiko
|
||||
- Algo-Trading → MiFID II Anforderungen
|
||||
- Robo Advisor → WpHG-Pflichten
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Transparenz bei Scoring-Entscheidungen
|
||||
2. Bias-Audits bei Kreditvergabe
|
||||
3. Human Oversight bei Ablehnungen
|
||||
4. DORA-konforme IT-Resilienz
|
||||
5. Incident Reporting
|
||||
|
||||
## Besondere Risiken
|
||||
- Diskriminierendes Kredit-Scoring (AGG + AI Act)
|
||||
- Ungerechtfertigte Verweigerung von Finanzdienstleistungen
|
||||
- Mangelnde Erklaerbarkeit bei Scoring-Algorithmen',
|
||||
'["Annex III Nr. 5 AI Act", "DORA", "MaRisk", "MiFID II", "Art. 22 DSGVO", "§ 1 AGG"]',
|
||||
ARRAY['Finance', 'Banking', 'Versicherung', 'Scoring', 'DORA', 'Kredit', 'Algo-Trading'],
|
||||
'critical',
|
||||
'[]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
@@ -104,6 +104,8 @@ func main() {
|
||||
auditHandlers := handlers.NewAuditHandlers(auditStore, exporter)
|
||||
uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry)
|
||||
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
|
||||
registrationStore := ucca.NewRegistrationStore(pool)
|
||||
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
|
||||
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||
@@ -270,10 +272,32 @@ func main() {
|
||||
uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview)
|
||||
uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation)
|
||||
|
||||
// AI Act Decision Tree
|
||||
dtRoutes := uccaRoutes.Group("/decision-tree")
|
||||
{
|
||||
dtRoutes.GET("", uccaHandlers.GetDecisionTree)
|
||||
dtRoutes.POST("/evaluate", uccaHandlers.EvaluateDecisionTree)
|
||||
dtRoutes.GET("/results", uccaHandlers.ListDecisionTreeResults)
|
||||
dtRoutes.GET("/results/:id", uccaHandlers.GetDecisionTreeResult)
|
||||
dtRoutes.DELETE("/results/:id", uccaHandlers.DeleteDecisionTreeResult)
|
||||
}
|
||||
|
||||
// Obligations framework (v2 with TOM mapping)
|
||||
obligationsHandlers.RegisterRoutes(uccaRoutes)
|
||||
}
|
||||
|
||||
// AI Registration routes - EU AI Database (Art. 49)
|
||||
regRoutes := v1.Group("/ai-registration")
|
||||
{
|
||||
regRoutes.POST("", registrationHandlers.Create)
|
||||
regRoutes.GET("", registrationHandlers.List)
|
||||
regRoutes.GET("/:id", registrationHandlers.Get)
|
||||
regRoutes.PUT("/:id", registrationHandlers.Update)
|
||||
regRoutes.PATCH("/:id/status", registrationHandlers.UpdateStatus)
|
||||
regRoutes.POST("/prefill/:assessment_id", registrationHandlers.Prefill)
|
||||
regRoutes.GET("/:id/export", registrationHandlers.Export)
|
||||
}
|
||||
|
||||
// RAG routes - Legal Corpus Search & Versioning
|
||||
ragRoutes := v1.Group("/rag")
|
||||
{
|
||||
|
||||
@@ -13,11 +13,14 @@ import (
|
||||
func TestAllowedCollections(t *testing.T) {
|
||||
allowed := []string{
|
||||
"bp_compliance_ce",
|
||||
"bp_compliance_recht",
|
||||
"bp_compliance_gesetze",
|
||||
"bp_compliance_datenschutz",
|
||||
"bp_compliance_gdpr",
|
||||
"bp_dsfa_corpus",
|
||||
"bp_dsfa_templates",
|
||||
"bp_dsfa_risks",
|
||||
"bp_legal_templates",
|
||||
"bp_iace_libraries",
|
||||
}
|
||||
|
||||
for _, c := range allowed {
|
||||
|
||||
220
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
220
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RegistrationHandlers handles EU AI Database registration endpoints
|
||||
type RegistrationHandlers struct {
|
||||
store *ucca.RegistrationStore
|
||||
uccaStore *ucca.Store
|
||||
}
|
||||
|
||||
// NewRegistrationHandlers creates new registration handlers
|
||||
func NewRegistrationHandlers(store *ucca.RegistrationStore, uccaStore *ucca.Store) *RegistrationHandlers {
|
||||
return &RegistrationHandlers{store: store, uccaStore: uccaStore}
|
||||
}
|
||||
|
||||
// Create creates a new registration
|
||||
func (h *RegistrationHandlers) Create(c *gin.Context) {
|
||||
var reg ucca.AIRegistration
|
||||
if err := c.ShouldBindJSON(®); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
reg.TenantID = tenantID
|
||||
|
||||
if reg.SystemName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system_name required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Create(c.Request.Context(), ®); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, reg)
|
||||
}
|
||||
|
||||
// List lists all registrations for the tenant
|
||||
func (h *RegistrationHandlers) List(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
registrations, err := h.store.List(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registrations: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if registrations == nil {
|
||||
registrations = []ucca.AIRegistration{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"registrations": registrations, "total": len(registrations)})
|
||||
}
|
||||
|
||||
// Get returns a single registration
|
||||
func (h *RegistrationHandlers) Get(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := h.store.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, reg)
|
||||
}
|
||||
|
||||
// Update updates a registration
|
||||
func (h *RegistrationHandlers) Update(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.store.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var updates ucca.AIRegistration
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Merge updates into existing
|
||||
updates.ID = existing.ID
|
||||
updates.TenantID = existing.TenantID
|
||||
updates.CreatedAt = existing.CreatedAt
|
||||
|
||||
if err := h.store.Update(c.Request.Context(), &updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updates)
|
||||
}
|
||||
|
||||
// UpdateStatus changes the registration status
|
||||
func (h *RegistrationHandlers) UpdateStatus(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
SubmittedBy string `json:"submitted_by"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := map[string]bool{
|
||||
"draft": true, "ready": true, "submitted": true,
|
||||
"registered": true, "update_required": true, "withdrawn": true,
|
||||
}
|
||||
if !validStatuses[body.Status] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status. Valid: draft, ready, submitted, registered, update_required, withdrawn"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateStatus(c.Request.Context(), id, body.Status, body.SubmittedBy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "status": body.Status})
|
||||
}
|
||||
|
||||
// Prefill creates a registration pre-filled from a UCCA assessment
|
||||
func (h *RegistrationHandlers) Prefill(c *gin.Context) {
|
||||
assessmentID, err := uuid.Parse(c.Param("assessment_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load UCCA assessment
|
||||
assessment, err := h.uccaStore.GetAssessment(c.Request.Context(), assessmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-fill registration from assessment intake
|
||||
intake := assessment.Intake
|
||||
|
||||
reg := ucca.AIRegistration{
|
||||
TenantID: tenantID,
|
||||
SystemName: intake.Title,
|
||||
SystemDescription: intake.UseCaseText,
|
||||
IntendedPurpose: intake.UseCaseText,
|
||||
RiskClassification: string(assessment.RiskLevel),
|
||||
GPAIClassification: "none",
|
||||
RegistrationStatus: "draft",
|
||||
UCCAAssessmentID: &assessmentID,
|
||||
}
|
||||
|
||||
// Map domain to readable text
|
||||
if intake.Domain != "" {
|
||||
reg.IntendedPurpose = string(intake.Domain) + ": " + intake.UseCaseText
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, reg)
|
||||
}
|
||||
|
||||
// Export generates the EU AI Database submission JSON
|
||||
func (h *RegistrationHandlers) Export(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := h.store.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
exportJSON := h.store.BuildExportJSON(reg)
|
||||
|
||||
// Save export data to DB
|
||||
reg.ExportData = exportJSON
|
||||
h.store.Update(c.Request.Context(), reg)
|
||||
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Header("Content-Disposition", "attachment; filename=eu_ai_registration_"+reg.SystemName+".json")
|
||||
c.Data(http.StatusOK, "application/json", exportJSON)
|
||||
}
|
||||
@@ -1122,6 +1122,114 @@ func (h *UCCAHandlers) GetWizardSchema(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Act Decision Tree Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GetDecisionTree returns the decision tree structure for the frontend
|
||||
// GET /sdk/v1/ucca/decision-tree
|
||||
func (h *UCCAHandlers) GetDecisionTree(c *gin.Context) {
|
||||
tree := ucca.BuildDecisionTreeDefinition()
|
||||
c.JSON(http.StatusOK, tree)
|
||||
}
|
||||
|
||||
// EvaluateDecisionTree evaluates the decision tree answers and stores the result
|
||||
// POST /sdk/v1/ucca/decision-tree/evaluate
|
||||
func (h *UCCAHandlers) EvaluateDecisionTree(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ucca.DecisionTreeEvalRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.SystemName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system_name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Evaluate
|
||||
result := ucca.EvaluateDecisionTree(&req)
|
||||
result.TenantID = tenantID
|
||||
|
||||
// Parse optional project_id
|
||||
if projectIDStr := c.Query("project_id"); projectIDStr != "" {
|
||||
if pid, err := uuid.Parse(projectIDStr); err == nil {
|
||||
result.ProjectID = &pid
|
||||
}
|
||||
}
|
||||
|
||||
// Store result
|
||||
if err := h.store.CreateDecisionTreeResult(c.Request.Context(), result); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// ListDecisionTreeResults returns stored decision tree results for a tenant
|
||||
// GET /sdk/v1/ucca/decision-tree/results
|
||||
func (h *UCCAHandlers) ListDecisionTreeResults(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.store.ListDecisionTreeResults(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"results": results, "total": len(results)})
|
||||
}
|
||||
|
||||
// GetDecisionTreeResult returns a single decision tree result by ID
|
||||
// GET /sdk/v1/ucca/decision-tree/results/:id
|
||||
func (h *UCCAHandlers) GetDecisionTreeResult(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.store.GetDecisionTreeResult(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DeleteDecisionTreeResult deletes a decision tree result
|
||||
// DELETE /sdk/v1/ucca/decision-tree/results/:id
|
||||
func (h *UCCAHandlers) DeleteDecisionTreeResult(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteDecisionTreeResult(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
305
ai-compliance-sdk/internal/ucca/betrvg_test.go
Normal file
305
ai-compliance-sdk/internal/ucca/betrvg_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG Conflict Score Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCalculateBetrvgConflictScore_NoEmployeeData(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: "Chatbot fuer Kunden-FAQ",
|
||||
Domain: DomainUtilities,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: false,
|
||||
PublicData: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.BetrvgConflictScore != 0 {
|
||||
t.Errorf("Expected BetrvgConflictScore 0 for non-employee case, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=false for non-employee case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_EmployeeMonitoring(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: "Teams Analytics mit Nutzungsstatistiken pro Mitarbeiter",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// employee_data(+10) + employee_monitoring(+20) + not_consulted(+5) = 35
|
||||
if result.BetrvgConflictScore < 30 {
|
||||
t.Errorf("Expected BetrvgConflictScore >= 30 for employee monitoring, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if !result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=true for employee monitoring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_HRDecisionSupport(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: "KI-gestuetztes Bewerber-Screening",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
HRDecisionSupport: true,
|
||||
Automation: "fully_automated",
|
||||
Outputs: Outputs{
|
||||
Rankings: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// employee_data(+10) + monitoring(+20) + hr(+20) + rankings(+10) + fully_auto(+10) + not_consulted(+5) = 75
|
||||
if result.BetrvgConflictScore < 70 {
|
||||
t.Errorf("Expected BetrvgConflictScore >= 70 for HR+monitoring+automated, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if !result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_ConsultedReducesScore(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)
|
||||
}
|
||||
|
||||
// Same as above but works council consulted
|
||||
intakeNotConsulted := &UseCaseIntake{
|
||||
UseCaseText: "Teams mit Nutzungsstatistiken",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
WorksCouncilConsulted: false,
|
||||
}
|
||||
|
||||
intakeConsulted := &UseCaseIntake{
|
||||
UseCaseText: "Teams mit Nutzungsstatistiken",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
WorksCouncilConsulted: true,
|
||||
}
|
||||
|
||||
resultNot := engine.Evaluate(intakeNotConsulted)
|
||||
resultYes := engine.Evaluate(intakeConsulted)
|
||||
|
||||
if resultYes.BetrvgConflictScore >= resultNot.BetrvgConflictScore {
|
||||
t.Errorf("Expected consulted score (%d) < not-consulted score (%d)",
|
||||
resultYes.BetrvgConflictScore, resultNot.BetrvgConflictScore)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG Escalation Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEscalation_BetrvgHighConflict_E3(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityCONDITIONAL,
|
||||
RiskLevel: RiskLevelMEDIUM,
|
||||
RiskScore: 45,
|
||||
BetrvgConflictScore: 80,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: false,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{
|
||||
{Code: "R-WARN-001", Severity: "WARN"},
|
||||
},
|
||||
}
|
||||
|
||||
level, reason := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
if level != EscalationLevelE3 {
|
||||
t.Errorf("Expected E3 for high BR conflict without consultation, got %s (reason: %s)", level, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalation_BetrvgMediumConflict_E2(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityCONDITIONAL,
|
||||
RiskLevel: RiskLevelLOW,
|
||||
RiskScore: 25,
|
||||
BetrvgConflictScore: 55,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: false,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{
|
||||
{Code: "R-WARN-001", Severity: "WARN"},
|
||||
},
|
||||
}
|
||||
|
||||
level, reason := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
if level != EscalationLevelE2 {
|
||||
t.Errorf("Expected E2 for medium BR conflict without consultation, got %s (reason: %s)", level, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalation_BetrvgConsulted_NoEscalation(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityYES,
|
||||
RiskLevel: RiskLevelLOW,
|
||||
RiskScore: 15,
|
||||
BetrvgConflictScore: 55,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: true,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{},
|
||||
}
|
||||
|
||||
level, _ := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
// With consultation done and low risk, should not escalate for BR reasons
|
||||
if level == EscalationLevelE3 {
|
||||
t.Error("Should not escalate to E3 when works council is consulted")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG V2 Obligations Loading Test
|
||||
// ============================================================================
|
||||
|
||||
func TestBetrvgV2_LoadsFromManifest(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
v2Dir := filepath.Join(root, "policies", "obligations", "v2")
|
||||
|
||||
// Check file exists
|
||||
betrvgPath := filepath.Join(v2Dir, "betrvg_v2.json")
|
||||
if _, err := os.Stat(betrvgPath); os.IsNotExist(err) {
|
||||
t.Fatal("betrvg_v2.json not found in policies/obligations/v2/")
|
||||
}
|
||||
|
||||
// Load all v2 regulations
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
betrvg, ok := regs["betrvg"]
|
||||
if !ok {
|
||||
t.Fatal("betrvg not found in loaded regulations")
|
||||
}
|
||||
|
||||
if betrvg.Regulation != "betrvg" {
|
||||
t.Errorf("Expected regulation 'betrvg', got '%s'", betrvg.Regulation)
|
||||
}
|
||||
|
||||
if len(betrvg.Obligations) < 10 {
|
||||
t.Errorf("Expected at least 10 BetrVG obligations, got %d", len(betrvg.Obligations))
|
||||
}
|
||||
|
||||
// Check first obligation has correct structure
|
||||
obl := betrvg.Obligations[0]
|
||||
if obl.ID != "BETRVG-OBL-001" {
|
||||
t.Errorf("Expected first obligation ID 'BETRVG-OBL-001', got '%s'", obl.ID)
|
||||
}
|
||||
if len(obl.LegalBasis) == 0 {
|
||||
t.Error("Expected legal basis for first obligation")
|
||||
}
|
||||
if obl.LegalBasis[0].Norm != "BetrVG" {
|
||||
t.Errorf("Expected norm 'BetrVG', got '%s'", obl.LegalBasis[0].Norm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetrvgApplicability_Germany(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
betrvgReg := regs["betrvg"]
|
||||
module := NewJSONRegulationModule(betrvgReg)
|
||||
|
||||
// German company with 50 employees — should be applicable
|
||||
factsDE := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "DE",
|
||||
EmployeeCount: 50,
|
||||
},
|
||||
}
|
||||
if !module.IsApplicable(factsDE) {
|
||||
t.Error("BetrVG should be applicable for German company with 50 employees")
|
||||
}
|
||||
|
||||
// US company — should NOT be applicable
|
||||
factsUS := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "US",
|
||||
EmployeeCount: 50,
|
||||
},
|
||||
}
|
||||
if module.IsApplicable(factsUS) {
|
||||
t.Error("BetrVG should NOT be applicable for US company")
|
||||
}
|
||||
|
||||
// German company with 3 employees — should NOT be applicable (threshold 5)
|
||||
factsSmall := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "DE",
|
||||
EmployeeCount: 3,
|
||||
},
|
||||
}
|
||||
if module.IsApplicable(factsSmall) {
|
||||
t.Error("BetrVG should NOT be applicable for company with < 5 employees")
|
||||
}
|
||||
}
|
||||
325
ai-compliance-sdk/internal/ucca/decision_tree_engine.go
Normal file
325
ai-compliance-sdk/internal/ucca/decision_tree_engine.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package ucca
|
||||
|
||||
// ============================================================================
|
||||
// AI Act Decision Tree Engine
|
||||
// ============================================================================
|
||||
//
|
||||
// Two-axis classification:
|
||||
// Axis 1 (Q1–Q7): High-Risk classification based on Annex III
|
||||
// Axis 2 (Q8–Q12): GPAI classification based on Art. 51–56
|
||||
//
|
||||
// Deterministic evaluation — no LLM involved.
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
// Question IDs
|
||||
const (
|
||||
Q1 = "Q1" // Uses AI?
|
||||
Q2 = "Q2" // Biometric identification?
|
||||
Q3 = "Q3" // Critical infrastructure?
|
||||
Q4 = "Q4" // Education / employment / HR?
|
||||
Q5 = "Q5" // Essential services (credit, insurance)?
|
||||
Q6 = "Q6" // Law enforcement / migration / justice?
|
||||
Q7 = "Q7" // Autonomous decisions with legal effect?
|
||||
Q8 = "Q8" // Foundation Model / GPAI?
|
||||
Q9 = "Q9" // Generates content (text, image, code, audio)?
|
||||
Q10 = "Q10" // Trained with >10^25 FLOP?
|
||||
Q11 = "Q11" // Model provided as API/service for third parties?
|
||||
Q12 = "Q12" // Significant EU market penetration?
|
||||
)
|
||||
|
||||
// BuildDecisionTreeDefinition returns the full decision tree structure for the frontend
|
||||
func BuildDecisionTreeDefinition() *DecisionTreeDefinition {
|
||||
return &DecisionTreeDefinition{
|
||||
ID: "ai_act_two_axis",
|
||||
Name: "AI Act Zwei-Achsen-Klassifikation",
|
||||
Version: "1.0.0",
|
||||
Questions: []DecisionTreeQuestion{
|
||||
// === Axis 1: High-Risk (Annex III) ===
|
||||
{
|
||||
ID: Q1,
|
||||
Axis: "high_risk",
|
||||
Question: "Setzt Ihr System KI-Technologie ein?",
|
||||
Description: "KI im Sinne des AI Act umfasst maschinelles Lernen, logik- und wissensbasierte Ansätze sowie statistische Methoden, die für eine gegebene Reihe von Zielen Ergebnisse wie Inhalte, Vorhersagen, Empfehlungen oder Entscheidungen erzeugen.",
|
||||
ArticleRef: "Art. 3 Nr. 1",
|
||||
},
|
||||
{
|
||||
ID: Q2,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System für biometrische Identifikation oder Kategorisierung natürlicher Personen verwendet?",
|
||||
Description: "Dazu zählen Gesichtserkennung, Stimmerkennung, Fingerabdruck-Analyse, Gangerkennung oder andere biometrische Merkmale zur Identifikation oder Kategorisierung.",
|
||||
ArticleRef: "Anhang III Nr. 1",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q3,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System in kritischer Infrastruktur eingesetzt (Energie, Verkehr, Wasser, digitale Infrastruktur)?",
|
||||
Description: "Betrifft KI-Systeme als Sicherheitskomponenten in der Verwaltung und dem Betrieb kritischer digitaler Infrastruktur, des Straßenverkehrs oder der Wasser-, Gas-, Heizungs- oder Stromversorgung.",
|
||||
ArticleRef: "Anhang III Nr. 2",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q4,
|
||||
Axis: "high_risk",
|
||||
Question: "Betrifft das System Bildung, Beschäftigung oder Personalmanagement?",
|
||||
Description: "KI zur Festlegung des Zugangs zu Bildungseinrichtungen, Bewertung von Prüfungsleistungen, Bewerbungsauswahl, Beförderungsentscheidungen oder Überwachung von Arbeitnehmern.",
|
||||
ArticleRef: "Anhang III Nr. 3–4",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q5,
|
||||
Axis: "high_risk",
|
||||
Question: "Betrifft das System den Zugang zu wesentlichen Diensten (Kreditvergabe, Versicherung, öffentliche Leistungen)?",
|
||||
Description: "KI zur Bonitätsbewertung, Risikobewertung bei Versicherungen, Bewertung der Anspruchsberechtigung für öffentliche Unterstützungsleistungen oder Notdienste.",
|
||||
ArticleRef: "Anhang III Nr. 5",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q6,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System in Strafverfolgung, Migration, Asyl oder Justiz eingesetzt?",
|
||||
Description: "KI für Lügendetektoren, Beweisbewertung, Rückfallprognose, Asylentscheidungen, Grenzkontrolle, Risikobewertung bei Migration oder Unterstützung der Rechtspflege.",
|
||||
ArticleRef: "Anhang III Nr. 6–8",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q7,
|
||||
Axis: "high_risk",
|
||||
Question: "Trifft das System autonome Entscheidungen mit rechtlicher Wirkung für natürliche Personen?",
|
||||
Description: "Entscheidungen, die Rechtsverhältnisse begründen, ändern oder aufheben, z.B. Kreditablehnungen, Kündigungen, Sozialleistungsentscheidungen — ohne menschliche Überprüfung im Einzelfall.",
|
||||
ArticleRef: "Art. 22 DSGVO / Art. 14 AI Act",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
|
||||
// === Axis 2: GPAI (Art. 51–56) ===
|
||||
{
|
||||
ID: Q8,
|
||||
Axis: "gpai",
|
||||
Question: "Stellst du ein KI-Modell fuer Dritte bereit (API / Plattform / SDK), das fuer viele verschiedene Zwecke einsetzbar ist?",
|
||||
Description: "GPAI-Pflichten (Art. 51-56) gelten fuer den Modellanbieter, nicht den API-Nutzer. Wenn du nur eine API nutzt (z.B. OpenAI, Claude), bist du kein GPAI-Anbieter. GPAI-Anbieter ist, wer ein Modell trainiert/fine-tuned und Dritten zur Verfuegung stellt. Beispiele: GPT, Claude, LLaMA, Gemini, Stable Diffusion.",
|
||||
ArticleRef: "Art. 3 Nr. 63 / Art. 51",
|
||||
},
|
||||
{
|
||||
ID: Q9,
|
||||
Axis: "gpai",
|
||||
Question: "Kann das System Inhalte generieren (Text, Bild, Code, Audio, Video)?",
|
||||
Description: "Generative KI erzeugt neue Inhalte auf Basis von Eingaben — dazu zählen Chatbots, Bild-/Videogeneratoren, Code-Assistenten, Sprachsynthese und ähnliche Systeme.",
|
||||
ArticleRef: "Art. 50 / Art. 52",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q10,
|
||||
Axis: "gpai",
|
||||
Question: "Wurde das Modell mit mehr als 10²⁵ FLOP trainiert oder hat es gleichwertige Fähigkeiten?",
|
||||
Description: "GPAI-Modelle mit einem kumulativen Rechenaufwand von mehr als 10²⁵ Gleitkommaoperationen gelten als Modelle mit systemischem Risiko (Art. 51 Abs. 2).",
|
||||
ArticleRef: "Art. 51 Abs. 2",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q11,
|
||||
Axis: "gpai",
|
||||
Question: "Wird das Modell als API oder Service für Dritte bereitgestellt?",
|
||||
Description: "Stellen Sie das Modell anderen Unternehmen oder Entwicklern zur Nutzung bereit (API, SaaS, Plattform-Integration)?",
|
||||
ArticleRef: "Art. 53",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q12,
|
||||
Axis: "gpai",
|
||||
Question: "Hat das Modell eine signifikante Marktdurchdringung in der EU (>10.000 registrierte Geschäftsnutzer)?",
|
||||
Description: "Modelle mit hoher Marktdurchdringung können auch ohne 10²⁵ FLOP als systemisches Risiko eingestuft werden, wenn die EU-Kommission dies feststellt.",
|
||||
ArticleRef: "Art. 51 Abs. 3",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateDecisionTree evaluates the answers and returns the combined result
|
||||
func EvaluateDecisionTree(req *DecisionTreeEvalRequest) *DecisionTreeResult {
|
||||
result := &DecisionTreeResult{
|
||||
SystemName: req.SystemName,
|
||||
SystemDescription: req.SystemDescription,
|
||||
Answers: req.Answers,
|
||||
}
|
||||
|
||||
// Evaluate Axis 1: High-Risk
|
||||
result.HighRiskResult = evaluateHighRiskAxis(req.Answers)
|
||||
|
||||
// Evaluate Axis 2: GPAI
|
||||
result.GPAIResult = evaluateGPAIAxis(req.Answers)
|
||||
|
||||
// Combine obligations and articles
|
||||
result.CombinedObligations = combineObligations(result.HighRiskResult, result.GPAIResult)
|
||||
result.ApplicableArticles = combineArticles(result.HighRiskResult, result.GPAIResult)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// evaluateHighRiskAxis determines the AI Act risk level from Q1–Q7
|
||||
func evaluateHighRiskAxis(answers map[string]DecisionTreeAnswer) AIActRiskLevel {
|
||||
// Q1: Uses AI at all?
|
||||
if !answerIsYes(answers, Q1) {
|
||||
return AIActNotApplicable
|
||||
}
|
||||
|
||||
// Q2–Q6: Annex III high-risk categories
|
||||
if answerIsYes(answers, Q2) || answerIsYes(answers, Q3) ||
|
||||
answerIsYes(answers, Q4) || answerIsYes(answers, Q5) ||
|
||||
answerIsYes(answers, Q6) {
|
||||
return AIActHighRisk
|
||||
}
|
||||
|
||||
// Q7: Autonomous decisions with legal effect
|
||||
if answerIsYes(answers, Q7) {
|
||||
return AIActHighRisk
|
||||
}
|
||||
|
||||
// AI is used but no high-risk category triggered
|
||||
return AIActMinimalRisk
|
||||
}
|
||||
|
||||
// evaluateGPAIAxis determines the GPAI classification from Q8–Q12
|
||||
func evaluateGPAIAxis(answers map[string]DecisionTreeAnswer) GPAIClassification {
|
||||
gpai := GPAIClassification{
|
||||
Category: GPAICategoryNone,
|
||||
ApplicableArticles: []string{},
|
||||
Obligations: []string{},
|
||||
}
|
||||
|
||||
// Q8: Is GPAI?
|
||||
if !answerIsYes(answers, Q8) {
|
||||
return gpai
|
||||
}
|
||||
|
||||
gpai.IsGPAI = true
|
||||
gpai.Category = GPAICategoryStandard
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 51", "Art. 53")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Technische Dokumentation erstellen (Art. 53 Abs. 1a)",
|
||||
"Informationen für nachgelagerte Anbieter bereitstellen (Art. 53 Abs. 1b)",
|
||||
"Urheberrechtsrichtlinie einhalten (Art. 53 Abs. 1c)",
|
||||
"Trainingsdaten-Zusammenfassung veröffentlichen (Art. 53 Abs. 1d)",
|
||||
)
|
||||
|
||||
// Q9: Generative AI — adds transparency obligations
|
||||
if answerIsYes(answers, Q9) {
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 50")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"KI-generierte Inhalte kennzeichnen (Art. 50 Abs. 2)",
|
||||
"Maschinenlesbare Kennzeichnung synthetischer Inhalte (Art. 50 Abs. 2)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q10: Systemic risk threshold (>10^25 FLOP)
|
||||
if answerIsYes(answers, Q10) {
|
||||
gpai.IsSystemicRisk = true
|
||||
gpai.Category = GPAICategorySystemic
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 55")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Modellbewertung nach Stand der Technik durchführen (Art. 55 Abs. 1a)",
|
||||
"Systemische Risiken bewerten und mindern (Art. 55 Abs. 1b)",
|
||||
"Schwerwiegende Vorfälle melden (Art. 55 Abs. 1c)",
|
||||
"Angemessenes Cybersicherheitsniveau gewährleisten (Art. 55 Abs. 1d)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q11: API/Service provider — additional downstream obligations
|
||||
if answerIsYes(answers, Q11) {
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Downstream-Informationspflichten erfüllen (Art. 53 Abs. 1b)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q12: Significant market penetration — potential systemic risk
|
||||
if answerIsYes(answers, Q12) && !gpai.IsSystemicRisk {
|
||||
// EU Commission can designate as systemic risk
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 51 Abs. 3")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Achtung: EU-Kommission kann GPAI mit hoher Marktdurchdringung als systemisches Risiko einstufen (Art. 51 Abs. 3)",
|
||||
)
|
||||
}
|
||||
|
||||
return gpai
|
||||
}
|
||||
|
||||
// combineObligations merges obligations from both axes
|
||||
func combineObligations(highRisk AIActRiskLevel, gpai GPAIClassification) []string {
|
||||
var obligations []string
|
||||
|
||||
// High-Risk obligations
|
||||
switch highRisk {
|
||||
case AIActHighRisk:
|
||||
obligations = append(obligations,
|
||||
"Risikomanagementsystem einrichten (Art. 9)",
|
||||
"Daten-Governance sicherstellen (Art. 10)",
|
||||
"Technische Dokumentation erstellen (Art. 11)",
|
||||
"Protokollierungsfunktion implementieren (Art. 12)",
|
||||
"Transparenz und Nutzerinformation (Art. 13)",
|
||||
"Menschliche Aufsicht ermöglichen (Art. 14)",
|
||||
"Genauigkeit, Robustheit und Cybersicherheit (Art. 15)",
|
||||
"EU-Datenbank-Registrierung (Art. 49)",
|
||||
)
|
||||
case AIActMinimalRisk:
|
||||
obligations = append(obligations,
|
||||
"Freiwillige Verhaltenskodizes empfohlen (Art. 95)",
|
||||
)
|
||||
case AIActNotApplicable:
|
||||
// No obligations
|
||||
}
|
||||
|
||||
// GPAI obligations
|
||||
obligations = append(obligations, gpai.Obligations...)
|
||||
|
||||
// Universal obligation for all AI users
|
||||
if highRisk != AIActNotApplicable {
|
||||
obligations = append(obligations,
|
||||
"KI-Kompetenz sicherstellen (Art. 4)",
|
||||
"Verbotene Praktiken vermeiden (Art. 5)",
|
||||
)
|
||||
}
|
||||
|
||||
return obligations
|
||||
}
|
||||
|
||||
// combineArticles merges applicable articles from both axes
|
||||
func combineArticles(highRisk AIActRiskLevel, gpai GPAIClassification) []string {
|
||||
articles := map[string]bool{}
|
||||
|
||||
// Universal
|
||||
if highRisk != AIActNotApplicable {
|
||||
articles["Art. 4"] = true
|
||||
articles["Art. 5"] = true
|
||||
}
|
||||
|
||||
// High-Risk
|
||||
switch highRisk {
|
||||
case AIActHighRisk:
|
||||
for _, a := range []string{"Art. 9", "Art. 10", "Art. 11", "Art. 12", "Art. 13", "Art. 14", "Art. 15", "Art. 26", "Art. 49"} {
|
||||
articles[a] = true
|
||||
}
|
||||
case AIActMinimalRisk:
|
||||
articles["Art. 95"] = true
|
||||
}
|
||||
|
||||
// GPAI
|
||||
for _, a := range gpai.ApplicableArticles {
|
||||
articles[a] = true
|
||||
}
|
||||
|
||||
var result []string
|
||||
for a := range articles {
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// answerIsYes checks if a question was answered with "yes" (true)
|
||||
func answerIsYes(answers map[string]DecisionTreeAnswer, questionID string) bool {
|
||||
a, ok := answers[questionID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return a.Value
|
||||
}
|
||||
420
ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go
Normal file
420
ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDecisionTreeDefinition_ReturnsValidTree(t *testing.T) {
|
||||
tree := BuildDecisionTreeDefinition()
|
||||
|
||||
if tree == nil {
|
||||
t.Fatal("Expected non-nil tree definition")
|
||||
}
|
||||
if tree.ID != "ai_act_two_axis" {
|
||||
t.Errorf("Expected ID 'ai_act_two_axis', got '%s'", tree.ID)
|
||||
}
|
||||
if tree.Version != "1.0.0" {
|
||||
t.Errorf("Expected version '1.0.0', got '%s'", tree.Version)
|
||||
}
|
||||
if len(tree.Questions) != 12 {
|
||||
t.Errorf("Expected 12 questions, got %d", len(tree.Questions))
|
||||
}
|
||||
|
||||
// Check axis distribution
|
||||
hrCount := 0
|
||||
gpaiCount := 0
|
||||
for _, q := range tree.Questions {
|
||||
switch q.Axis {
|
||||
case "high_risk":
|
||||
hrCount++
|
||||
case "gpai":
|
||||
gpaiCount++
|
||||
default:
|
||||
t.Errorf("Unexpected axis '%s' for question %s", q.Axis, q.ID)
|
||||
}
|
||||
}
|
||||
if hrCount != 7 {
|
||||
t.Errorf("Expected 7 high_risk questions, got %d", hrCount)
|
||||
}
|
||||
if gpaiCount != 5 {
|
||||
t.Errorf("Expected 5 gpai questions, got %d", gpaiCount)
|
||||
}
|
||||
|
||||
// Check all questions have required fields
|
||||
for _, q := range tree.Questions {
|
||||
if q.ID == "" {
|
||||
t.Error("Question has empty ID")
|
||||
}
|
||||
if q.Question == "" {
|
||||
t.Errorf("Question %s has empty question text", q.ID)
|
||||
}
|
||||
if q.Description == "" {
|
||||
t.Errorf("Question %s has empty description", q.ID)
|
||||
}
|
||||
if q.ArticleRef == "" {
|
||||
t.Errorf("Question %s has empty article_ref", q.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_NotApplicable(t *testing.T) {
|
||||
// Q1=No → AI Act not applicable
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Test System",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActNotApplicable {
|
||||
t.Errorf("Expected not_applicable, got %s", result.HighRiskResult)
|
||||
}
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be false when Q8 is not answered")
|
||||
}
|
||||
if result.SystemName != "Test System" {
|
||||
t.Errorf("Expected system name 'Test System', got '%s'", result.SystemName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_MinimalRisk(t *testing.T) {
|
||||
// Q1=Yes, Q2-Q7=No → minimal risk
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Simple Tool",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
Q8: {QuestionID: Q8, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActMinimalRisk {
|
||||
t.Errorf("Expected minimal_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be false")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryNone {
|
||||
t.Errorf("Expected GPAI category 'none', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_Biometric(t *testing.T) {
|
||||
// Q1=Yes, Q2=Yes → high risk (biometric)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Face Recognition",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: true},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
|
||||
// Should have high-risk obligations
|
||||
if len(result.CombinedObligations) == 0 {
|
||||
t.Error("Expected non-empty obligations for high-risk system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_CriticalInfrastructure(t *testing.T) {
|
||||
// Q1=Yes, Q3=Yes → high risk (critical infrastructure)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Energy Grid AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: true},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_Education(t *testing.T) {
|
||||
// Q1=Yes, Q4=Yes → high risk (education/employment)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Exam Grading AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_AutonomousDecisions(t *testing.T) {
|
||||
// Q1=Yes, Q7=Yes → high risk (autonomous decisions)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Credit Scoring AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_Standard(t *testing.T) {
|
||||
// Q8=Yes, Q10=No → GPAI standard
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Custom LLM",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: false},
|
||||
Q12: {QuestionID: Q12, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected category 'standard', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if result.GPAIResult.IsSystemicRisk {
|
||||
t.Error("Expected IsSystemicRisk to be false")
|
||||
}
|
||||
|
||||
// Should have Art. 51, 53, 50 (generative)
|
||||
hasArt51 := false
|
||||
hasArt53 := false
|
||||
hasArt50 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 51" {
|
||||
hasArt51 = true
|
||||
}
|
||||
if a == "Art. 53" {
|
||||
hasArt53 = true
|
||||
}
|
||||
if a == "Art. 50" {
|
||||
hasArt50 = true
|
||||
}
|
||||
}
|
||||
if !hasArt51 {
|
||||
t.Error("Expected Art. 51 in applicable articles")
|
||||
}
|
||||
if !hasArt53 {
|
||||
t.Error("Expected Art. 53 in applicable articles")
|
||||
}
|
||||
if !hasArt50 {
|
||||
t.Error("Expected Art. 50 in applicable articles (generative AI)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_SystemicRisk(t *testing.T) {
|
||||
// Q8=Yes, Q10=Yes → GPAI systemic risk
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "GPT-5",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: true},
|
||||
Q11: {QuestionID: Q11, Value: true},
|
||||
Q12: {QuestionID: Q12, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategorySystemic {
|
||||
t.Errorf("Expected category 'systemic', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if !result.GPAIResult.IsSystemicRisk {
|
||||
t.Error("Expected IsSystemicRisk to be true")
|
||||
}
|
||||
|
||||
// Should have Art. 55
|
||||
hasArt55 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 55" {
|
||||
hasArt55 = true
|
||||
}
|
||||
}
|
||||
if !hasArt55 {
|
||||
t.Error("Expected Art. 55 in applicable articles (systemic risk)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_Combined_HighRiskAndGPAI(t *testing.T) {
|
||||
// Q1=Yes, Q4=Yes (high risk) + Q8=Yes, Q9=Yes (GPAI standard)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "HR Screening with LLM",
|
||||
SystemDescription: "LLM-based applicant screening system",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: true},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: false},
|
||||
Q12: {QuestionID: Q12, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
// Both axes should be triggered
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected GPAI category 'standard', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
|
||||
// Combined obligations should include both axes
|
||||
if len(result.CombinedObligations) < 5 {
|
||||
t.Errorf("Expected at least 5 combined obligations, got %d", len(result.CombinedObligations))
|
||||
}
|
||||
|
||||
// Should have articles from both axes
|
||||
if len(result.ApplicableArticles) < 3 {
|
||||
t.Errorf("Expected at least 3 applicable articles, got %d", len(result.ApplicableArticles))
|
||||
}
|
||||
|
||||
// Check system name preserved
|
||||
if result.SystemName != "HR Screening with LLM" {
|
||||
t.Errorf("Expected system name preserved, got '%s'", result.SystemName)
|
||||
}
|
||||
if result.SystemDescription != "LLM-based applicant screening system" {
|
||||
t.Errorf("Expected description preserved, got '%s'", result.SystemDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_MarketPenetration(t *testing.T) {
|
||||
// Q8=Yes, Q10=No, Q12=Yes → GPAI standard with market penetration warning
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Popular Chatbot",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: true},
|
||||
Q12: {QuestionID: Q12, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected category 'standard' (not systemic because Q10=No), got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
|
||||
// Should have Art. 51 Abs. 3 warning
|
||||
hasArt51_3 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 51 Abs. 3" {
|
||||
hasArt51_3 = true
|
||||
}
|
||||
}
|
||||
if !hasArt51_3 {
|
||||
t.Error("Expected Art. 51 Abs. 3 in applicable articles for high market penetration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_NoGPAI(t *testing.T) {
|
||||
// Q8=No → No GPAI classification
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Traditional ML",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be false")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryNone {
|
||||
t.Errorf("Expected category 'none', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if len(result.GPAIResult.Obligations) != 0 {
|
||||
t.Errorf("Expected 0 GPAI obligations, got %d", len(result.GPAIResult.Obligations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerIsYes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
answers map[string]DecisionTreeAnswer
|
||||
qID string
|
||||
expected bool
|
||||
}{
|
||||
{"yes answer", map[string]DecisionTreeAnswer{"Q1": {Value: true}}, "Q1", true},
|
||||
{"no answer", map[string]DecisionTreeAnswer{"Q1": {Value: false}}, "Q1", false},
|
||||
{"missing answer", map[string]DecisionTreeAnswer{}, "Q1", false},
|
||||
{"different question", map[string]DecisionTreeAnswer{"Q2": {Value: true}}, "Q1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := answerIsYes(tt.answers, tt.qID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
542
ai-compliance-sdk/internal/ucca/domain_context_test.go
Normal file
542
ai-compliance-sdk/internal/ucca/domain_context_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// HR Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestHRContext_AutomatedRejection_BLOCK(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: "KI generiert und versendet Absagen automatisch",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{
|
||||
AutomatedScreening: true,
|
||||
AutomatedRejection: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO feasibility for automated rejection, got %s", result.Feasibility)
|
||||
}
|
||||
if !result.Art22Risk {
|
||||
t.Error("Expected Art22Risk=true for automated rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHRContext_ScreeningWithHumanReview_OK(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: "KI sortiert Bewerber vor, Mensch prueft jeden Vorschlag",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{
|
||||
AutomatedScreening: true,
|
||||
AutomatedRejection: false,
|
||||
HumanReviewEnforced: true,
|
||||
BiasAuditsDone: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// Should NOT block — human review is enforced
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when human review is enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHRContext_AGGVisible_RiskIncrease(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)
|
||||
}
|
||||
|
||||
intakeWithAGG := &UseCaseIntake{
|
||||
UseCaseText: "CV-Screening mit Foto und Name sichtbar",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{AGGCategoriesVisible: true},
|
||||
}
|
||||
intakeWithout := &UseCaseIntake{
|
||||
UseCaseText: "CV-Screening anonymisiert",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{AGGCategoriesVisible: false},
|
||||
}
|
||||
|
||||
resultWith := engine.Evaluate(intakeWithAGG)
|
||||
resultWithout := engine.Evaluate(intakeWithout)
|
||||
|
||||
if resultWith.RiskScore <= resultWithout.RiskScore {
|
||||
t.Errorf("Expected higher risk with AGG visible (%d) vs without (%d)",
|
||||
resultWith.RiskScore, resultWithout.RiskScore)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Education Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEducationContext_MinorsWithoutTeacher_BLOCK(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: "KI bewertet Schuelerarbeiten ohne Lehrkraft-Pruefung",
|
||||
Domain: DomainEducation,
|
||||
DataTypes: DataTypes{PersonalData: true, MinorData: true},
|
||||
EducationContext: &EducationContext{
|
||||
GradeInfluence: true,
|
||||
MinorsInvolved: true,
|
||||
TeacherReviewRequired: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO feasibility for minors without teacher review, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationContext_WithTeacherReview_Allowed(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: "KI schlaegt Noten vor, Lehrkraft prueft und entscheidet",
|
||||
Domain: DomainEducation,
|
||||
DataTypes: DataTypes{PersonalData: true, MinorData: true},
|
||||
EducationContext: &EducationContext{
|
||||
GradeInfluence: true,
|
||||
MinorsInvolved: true,
|
||||
TeacherReviewRequired: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when teacher review is required")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Healthcare Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestHealthcareContext_MDRWithoutValidation_BLOCK(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: "KI-Diagnosetool als Medizinprodukt ohne klinische Validierung",
|
||||
Domain: DomainHealthcare,
|
||||
DataTypes: DataTypes{PersonalData: true, Article9Data: true},
|
||||
HealthcareContext: &HealthcareContext{
|
||||
DiagnosisSupport: true,
|
||||
MedicalDevice: true,
|
||||
ClinicalValidation: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for medical device without clinical validation, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcareContext_Triage_HighRisk(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: "KI priorisiert Patienten in der Notaufnahme",
|
||||
Domain: DomainHealthcare,
|
||||
DataTypes: DataTypes{PersonalData: true, Article9Data: true},
|
||||
HealthcareContext: &HealthcareContext{
|
||||
TriageDecision: true,
|
||||
PatientDataProcessed: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.RiskScore < 40 {
|
||||
t.Errorf("Expected high risk score for triage, got %d", result.RiskScore)
|
||||
}
|
||||
if !result.DSFARecommended {
|
||||
t.Error("Expected DSFA recommended for triage")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Critical Infrastructure Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCriticalInfra_SafetyCriticalNoRedundancy_BLOCK(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: "KI steuert Stromnetz ohne Fallback",
|
||||
Domain: DomainEnergy,
|
||||
CriticalInfraContext: &CriticalInfraContext{
|
||||
GridControl: true,
|
||||
SafetyCritical: true,
|
||||
RedundancyExists: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for safety-critical without redundancy, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Marketing — Deepfake BLOCK Test
|
||||
// ============================================================================
|
||||
|
||||
func TestMarketing_DeepfakeUnlabeled_BLOCK(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: "KI generiert Werbevideos ohne Kennzeichnung",
|
||||
Domain: DomainMarketing,
|
||||
MarketingContext: &MarketingContext{
|
||||
DeepfakeContent: true,
|
||||
AIContentLabeled: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for unlabeled deepfakes, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketing_DeepfakeLabeled_OK(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: "KI generiert Werbevideos mit Kennzeichnung",
|
||||
Domain: DomainMarketing,
|
||||
MarketingContext: &MarketingContext{
|
||||
DeepfakeContent: true,
|
||||
AIContentLabeled: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when deepfakes are properly labeled")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Manufacturing — Safety BLOCK Test
|
||||
// ============================================================================
|
||||
|
||||
func TestManufacturing_SafetyUnvalidated_BLOCK(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: "KI in Maschinensicherheit ohne Validierung",
|
||||
Domain: DomainMechanicalEngineering,
|
||||
ManufacturingContext: &ManufacturingContext{
|
||||
MachineSafety: true,
|
||||
SafetyValidated: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for unvalidated machine safety, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGG V2 Obligations Loading Test
|
||||
// ============================================================================
|
||||
|
||||
func TestAGGV2_LoadsFromManifest(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
agg, ok := regs["agg"]
|
||||
if !ok {
|
||||
t.Fatal("agg not found in loaded regulations")
|
||||
}
|
||||
|
||||
if len(agg.Obligations) < 8 {
|
||||
t.Errorf("Expected at least 8 AGG obligations, got %d", len(agg.Obligations))
|
||||
}
|
||||
|
||||
// Check first obligation
|
||||
if agg.Obligations[0].ID != "AGG-OBL-001" {
|
||||
t.Errorf("Expected first ID 'AGG-OBL-001', got '%s'", agg.Obligations[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGGApplicability_Germany(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
module := NewJSONRegulationModule(regs["agg"])
|
||||
|
||||
factsDE := &UnifiedFacts{Organization: OrganizationFacts{Country: "DE"}}
|
||||
if !module.IsApplicable(factsDE) {
|
||||
t.Error("AGG should be applicable for German company")
|
||||
}
|
||||
|
||||
factsUS := &UnifiedFacts{Organization: OrganizationFacts{Country: "US"}}
|
||||
if module.IsApplicable(factsUS) {
|
||||
t.Error("AGG should NOT be applicable for US company")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Act V2 Extended Obligations Test
|
||||
// ============================================================================
|
||||
|
||||
func TestAIActV2_ExtendedObligations(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
aiAct, ok := regs["ai_act"]
|
||||
if !ok {
|
||||
t.Fatal("ai_act not found in loaded regulations")
|
||||
}
|
||||
|
||||
if len(aiAct.Obligations) < 75 {
|
||||
t.Errorf("Expected at least 75 AI Act obligations (expanded), got %d", len(aiAct.Obligations))
|
||||
}
|
||||
|
||||
// Check GPAI obligations exist (Art. 51-56)
|
||||
hasGPAI := false
|
||||
for _, obl := range aiAct.Obligations {
|
||||
if obl.ID == "AIACT-OBL-078" { // GPAI classification
|
||||
hasGPAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGPAI {
|
||||
t.Error("Expected GPAI obligation AIACT-OBL-078 in expanded AI Act")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Field Resolver Tests — Domain Contexts
|
||||
// ============================================================================
|
||||
|
||||
func TestFieldResolver_HRContext(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{
|
||||
HRContext: &HRContext{AutomatedScreening: true},
|
||||
}
|
||||
|
||||
val := engine.getFieldValue("hr_context.automated_screening", intake)
|
||||
if val != true {
|
||||
t.Errorf("Expected true for hr_context.automated_screening, got %v", val)
|
||||
}
|
||||
|
||||
val2 := engine.getFieldValue("hr_context.automated_rejection", intake)
|
||||
if val2 != false {
|
||||
t.Errorf("Expected false for hr_context.automated_rejection, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResolver_NilContext(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{} // No HR context
|
||||
|
||||
val := engine.getFieldValue("hr_context.automated_screening", intake)
|
||||
if val != nil {
|
||||
t.Errorf("Expected nil for nil HR context, got %v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResolver_HealthcareContext(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{
|
||||
HealthcareContext: &HealthcareContext{
|
||||
TriageDecision: true,
|
||||
MedicalDevice: false,
|
||||
},
|
||||
}
|
||||
|
||||
val := engine.getFieldValue("healthcare_context.triage_decision", intake)
|
||||
if val != true {
|
||||
t.Errorf("Expected true, got %v", val)
|
||||
}
|
||||
|
||||
val2 := engine.getFieldValue("healthcare_context.medical_device", intake)
|
||||
if val2 != false {
|
||||
t.Errorf("Expected false, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hospitality — Review Manipulation BLOCK
|
||||
// ============================================================================
|
||||
|
||||
func TestHospitality_ReviewManipulation_BLOCK(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: "KI generiert Fake-Bewertungen",
|
||||
Domain: DomainHospitality,
|
||||
HospitalityContext: &HospitalityContext{
|
||||
ReviewManipulation: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for review manipulation, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Total Obligations Count
|
||||
// ============================================================================
|
||||
|
||||
func TestTotalObligationsCount(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, reg := range regs {
|
||||
total += len(reg.Obligations)
|
||||
}
|
||||
|
||||
// We expect at least 350 obligations across all regulations
|
||||
if total < 350 {
|
||||
t.Errorf("Expected at least 350 total obligations, got %d", total)
|
||||
}
|
||||
|
||||
t.Logf("Total obligations across all regulations: %d", total)
|
||||
for id, reg := range regs {
|
||||
t.Logf(" %s: %d obligations", id, len(reg.Obligations))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Domain constant existence checks
|
||||
// ============================================================================
|
||||
|
||||
func TestDomainConstants_Exist(t *testing.T) {
|
||||
domains := []Domain{
|
||||
DomainHR, DomainEducation, DomainHealthcare,
|
||||
DomainFinance, DomainBanking, DomainInsurance,
|
||||
DomainEnergy, DomainUtilities,
|
||||
DomainAutomotive, DomainAerospace,
|
||||
DomainRetail, DomainEcommerce,
|
||||
DomainMarketing, DomainMedia,
|
||||
DomainLogistics, DomainConstruction,
|
||||
DomainPublicSector, DomainDefense,
|
||||
DomainMechanicalEngineering,
|
||||
}
|
||||
|
||||
for _, d := range domains {
|
||||
if d == "" {
|
||||
t.Error("Empty domain constant found")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -187,6 +188,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
||||
}
|
||||
}
|
||||
|
||||
// BetrVG E3: Very high conflict score without consultation
|
||||
if result.BetrvgConflictScore >= 75 && !result.Intake.WorksCouncilConsulted {
|
||||
reasons = append(reasons, "BetrVG-Konfliktpotenzial sehr hoch (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+") ohne BR-Konsultation")
|
||||
return EscalationLevelE3, joinReasons(reasons, "E3 erforderlich: ")
|
||||
}
|
||||
|
||||
if hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold {
|
||||
if result.DSFARecommended {
|
||||
reasons = append(reasons, "DSFA empfohlen")
|
||||
@@ -197,6 +204,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
||||
return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ")
|
||||
}
|
||||
|
||||
// BetrVG E2: High conflict score
|
||||
if result.BetrvgConflictScore >= 50 && result.BetrvgConsultationRequired && !result.Intake.WorksCouncilConsulted {
|
||||
reasons = append(reasons, "BetrVG-Mitbestimmung erforderlich (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+"), BR nicht konsultiert")
|
||||
return EscalationLevelE2, joinReasons(reasons, "BR-Konsultation erforderlich: ")
|
||||
}
|
||||
|
||||
// E1: Low priority checks
|
||||
// - WARN rules triggered
|
||||
// - Risk 20-40
|
||||
|
||||
@@ -56,6 +56,10 @@ func (m *JSONRegulationModule) defaultApplicability(facts *UnifiedFacts) bool {
|
||||
return facts.Organization.EUMember && facts.AIUsage.UsesAI
|
||||
case "dora":
|
||||
return facts.Financial.DORAApplies || facts.Financial.IsRegulated
|
||||
case "betrvg":
|
||||
return facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 5
|
||||
case "agg":
|
||||
return facts.Organization.Country == "DE"
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type LegalRAGClient struct {
|
||||
embeddingModel string
|
||||
collection string
|
||||
httpClient *http.Client
|
||||
textIndexEnsured map[string]bool // tracks which collections have text index
|
||||
hybridEnabled bool // use Query API with RRF fusion
|
||||
}
|
||||
|
||||
// LegalSearchResult represents a single search result from the compliance corpus.
|
||||
@@ -70,12 +72,16 @@ func NewLegalRAGClient() *LegalRAGClient {
|
||||
ollamaURL = "http://localhost:11434"
|
||||
}
|
||||
|
||||
hybridEnabled := os.Getenv("RAG_HYBRID_SEARCH") != "false" // enabled by default
|
||||
|
||||
return &LegalRAGClient{
|
||||
qdrantURL: qdrantURL,
|
||||
qdrantAPIKey: qdrantAPIKey,
|
||||
ollamaURL: ollamaURL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: hybridEnabled,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
@@ -126,6 +132,161 @@ type qdrantSearchHit struct {
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// --- Hybrid Search (Query API with RRF fusion) ---
|
||||
|
||||
// qdrantQueryRequest for Qdrant Query API with prefetch + fusion.
|
||||
type qdrantQueryRequest struct {
|
||||
Prefetch []qdrantPrefetch `json:"prefetch"`
|
||||
Query *qdrantFusion `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
WithPayload bool `json:"with_payload"`
|
||||
Filter *qdrantFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
type qdrantPrefetch struct {
|
||||
Query []float64 `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
Filter *qdrantFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
type qdrantFusion struct {
|
||||
Fusion string `json:"fusion"`
|
||||
}
|
||||
|
||||
// qdrantQueryResponse from Qdrant Query API (same shape as search).
|
||||
type qdrantQueryResponse struct {
|
||||
Result []qdrantSearchHit `json:"result"`
|
||||
}
|
||||
|
||||
// qdrantTextIndexRequest for creating a full-text index on a payload field.
|
||||
type qdrantTextIndexRequest struct {
|
||||
FieldName string `json:"field_name"`
|
||||
FieldSchema qdrantTextFieldSchema `json:"field_schema"`
|
||||
}
|
||||
|
||||
type qdrantTextFieldSchema struct {
|
||||
Type string `json:"type"`
|
||||
Tokenizer string `json:"tokenizer"`
|
||||
MinLen int `json:"min_token_len,omitempty"`
|
||||
MaxLen int `json:"max_token_len,omitempty"`
|
||||
}
|
||||
|
||||
// ensureTextIndex creates a full-text index on chunk_text if not already done for this collection.
|
||||
func (c *LegalRAGClient) ensureTextIndex(ctx context.Context, collection string) error {
|
||||
if c.textIndexEnsured[collection] {
|
||||
return nil
|
||||
}
|
||||
|
||||
indexReq := qdrantTextIndexRequest{
|
||||
FieldName: "chunk_text",
|
||||
FieldSchema: qdrantTextFieldSchema{
|
||||
Type: "text",
|
||||
Tokenizer: "word",
|
||||
MinLen: 2,
|
||||
MaxLen: 40,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(indexReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal text index request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/collections/%s/index", c.qdrantURL, collection)
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create text index request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.qdrantAPIKey != "" {
|
||||
req.Header.Set("api-key", c.qdrantAPIKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("text index request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 200 = created, 409 = already exists — both are fine
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("text index creation failed %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
c.textIndexEnsured[collection] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchHybrid performs RRF-fused hybrid search (dense + full-text) via Qdrant Query API.
|
||||
func (c *LegalRAGClient) searchHybrid(ctx context.Context, collection string, embedding []float64, regulationIDs []string, topK int) ([]qdrantSearchHit, error) {
|
||||
// Ensure text index exists
|
||||
if err := c.ensureTextIndex(ctx, collection); err != nil {
|
||||
// Non-fatal: log and fall back to dense-only
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build prefetch with dense vector (retrieve top-20 for re-ranking)
|
||||
prefetchLimit := 20
|
||||
if topK > 20 {
|
||||
prefetchLimit = topK * 4
|
||||
}
|
||||
|
||||
queryReq := qdrantQueryRequest{
|
||||
Prefetch: []qdrantPrefetch{
|
||||
{Query: embedding, Limit: prefetchLimit},
|
||||
},
|
||||
Query: &qdrantFusion{Fusion: "rrf"},
|
||||
Limit: topK,
|
||||
WithPayload: true,
|
||||
}
|
||||
|
||||
// Add regulation filter
|
||||
if len(regulationIDs) > 0 {
|
||||
conditions := make([]qdrantCondition, len(regulationIDs))
|
||||
for i, regID := range regulationIDs {
|
||||
conditions[i] = qdrantCondition{
|
||||
Key: "regulation_id",
|
||||
Match: qdrantMatch{Value: regID},
|
||||
}
|
||||
}
|
||||
queryReq.Filter = &qdrantFilter{Should: conditions}
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(queryReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal query request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/collections/%s/points/query", c.qdrantURL, collection)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create query request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.qdrantAPIKey != "" {
|
||||
req.Header.Set("api-key", c.qdrantAPIKey)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("qdrant query returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var queryResp qdrantQueryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&queryResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode query response: %w", err)
|
||||
}
|
||||
|
||||
return queryResp.Result, nil
|
||||
}
|
||||
|
||||
// generateEmbedding calls Ollama bge-m3 to get a 1024-dim vector for the query.
|
||||
func (c *LegalRAGClient) generateEmbedding(ctx context.Context, text string) ([]float64, error) {
|
||||
// Truncate to 2000 chars for bge-m3
|
||||
@@ -187,6 +348,8 @@ func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationIDs
|
||||
}
|
||||
|
||||
// searchInternal performs the actual search against a given collection.
|
||||
// If hybrid search is enabled, it uses the Qdrant Query API with RRF fusion
|
||||
// (dense + full-text). Falls back to dense-only /points/search on failure.
|
||||
func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) {
|
||||
// Generate query embedding via Ollama bge-m3
|
||||
embedding, err := c.generateEmbedding(ctx, query)
|
||||
@@ -194,14 +357,51 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
|
||||
return nil, fmt.Errorf("failed to generate embedding: %w", err)
|
||||
}
|
||||
|
||||
// Build Qdrant search request
|
||||
// Try hybrid search first (Query API + RRF), fall back to dense-only
|
||||
var hits []qdrantSearchHit
|
||||
|
||||
if c.hybridEnabled {
|
||||
hybridHits, err := c.searchHybrid(ctx, collection, embedding, regulationIDs, topK)
|
||||
if err == nil {
|
||||
hits = hybridHits
|
||||
}
|
||||
// On error, fall through to dense-only search below
|
||||
}
|
||||
|
||||
if hits == nil {
|
||||
denseHits, err := c.searchDense(ctx, collection, embedding, regulationIDs, topK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hits = denseHits
|
||||
}
|
||||
|
||||
// Convert to results using bp_compliance_ce payload schema
|
||||
results := make([]LegalSearchResult, len(hits))
|
||||
for i, hit := range hits {
|
||||
results[i] = LegalSearchResult{
|
||||
Text: getString(hit.Payload, "chunk_text"),
|
||||
RegulationCode: getString(hit.Payload, "regulation_id"),
|
||||
RegulationName: getString(hit.Payload, "regulation_name_de"),
|
||||
RegulationShort: getString(hit.Payload, "regulation_short"),
|
||||
Category: getString(hit.Payload, "category"),
|
||||
Pages: getIntSlice(hit.Payload, "pages"),
|
||||
SourceURL: getString(hit.Payload, "source"),
|
||||
Score: hit.Score,
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// searchDense performs a dense-only vector search via Qdrant /points/search.
|
||||
func (c *LegalRAGClient) searchDense(ctx context.Context, collection string, embedding []float64, regulationIDs []string, topK int) ([]qdrantSearchHit, error) {
|
||||
searchReq := qdrantSearchRequest{
|
||||
Vector: embedding,
|
||||
Limit: topK,
|
||||
WithPayload: true,
|
||||
}
|
||||
|
||||
// Add filter for specific regulations if provided
|
||||
if len(regulationIDs) > 0 {
|
||||
conditions := make([]qdrantCondition, len(regulationIDs))
|
||||
for i, regID := range regulationIDs {
|
||||
@@ -218,7 +418,6 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
|
||||
return nil, fmt.Errorf("failed to marshal search request: %w", err)
|
||||
}
|
||||
|
||||
// Call Qdrant
|
||||
url := fmt.Sprintf("%s/collections/%s/points/search", c.qdrantURL, collection)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
@@ -245,22 +444,7 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
|
||||
return nil, fmt.Errorf("failed to decode search response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to results using bp_compliance_ce payload schema
|
||||
results := make([]LegalSearchResult, len(searchResp.Result))
|
||||
for i, hit := range searchResp.Result {
|
||||
results[i] = LegalSearchResult{
|
||||
Text: getString(hit.Payload, "chunk_text"),
|
||||
RegulationCode: getString(hit.Payload, "regulation_id"),
|
||||
RegulationName: getString(hit.Payload, "regulation_name_de"),
|
||||
RegulationShort: getString(hit.Payload, "regulation_short"),
|
||||
Category: getString(hit.Payload, "category"),
|
||||
Pages: getIntSlice(hit.Payload, "pages"),
|
||||
SourceURL: getString(hit.Payload, "source"),
|
||||
Score: hit.Score,
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
return searchResp.Result, nil
|
||||
}
|
||||
|
||||
// GetLegalContextForAssessment retrieves relevant legal context for an assessment.
|
||||
|
||||
@@ -36,6 +36,8 @@ func TestSearchCollection_UsesCorrectCollection(t *testing.T) {
|
||||
ollamaURL: ollamaMock.URL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: false, // dense-only for this test
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -73,6 +75,8 @@ func TestSearchCollection_FallbackDefault(t *testing.T) {
|
||||
ollamaURL: ollamaMock.URL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: false,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -141,6 +145,7 @@ func TestScrollChunks_ReturnsChunksAndNextOffset(t *testing.T) {
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -197,6 +202,7 @@ func TestScrollChunks_EmptyCollection_ReturnsEmpty(t *testing.T) {
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -231,6 +237,7 @@ func TestScrollChunks_WithOffset_SendsOffset(t *testing.T) {
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -265,6 +272,7 @@ func TestScrollChunks_SendsAPIKey(t *testing.T) {
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
qdrantAPIKey: "test-api-key-123",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -314,6 +322,8 @@ func TestSearch_StillWorks(t *testing.T) {
|
||||
ollamaURL: ollamaMock.URL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: false,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
@@ -334,3 +344,257 @@ func TestSearch_StillWorks(t *testing.T) {
|
||||
t.Errorf("Expected default collection in URL, got: %s", requestedURL)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hybrid Search Tests ---
|
||||
|
||||
func TestHybridSearch_UsesQueryAPI(t *testing.T) {
|
||||
var requestedPaths []string
|
||||
|
||||
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{
|
||||
Embedding: make([]float64, 1024),
|
||||
})
|
||||
}))
|
||||
defer ollamaMock.Close()
|
||||
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPaths = append(requestedPaths, r.URL.Path)
|
||||
|
||||
if strings.Contains(r.URL.Path, "/index") {
|
||||
// Text index creation — return OK
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"result":{"operation_id":1,"status":"completed"}}`))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(r.URL.Path, "/points/query") {
|
||||
// Verify the query request body has prefetch + fusion
|
||||
var reqBody map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
if _, ok := reqBody["prefetch"]; !ok {
|
||||
t.Error("Query request missing 'prefetch' field")
|
||||
}
|
||||
queryField, ok := reqBody["query"].(map[string]interface{})
|
||||
if !ok || queryField["fusion"] != "rrf" {
|
||||
t.Error("Query request missing 'query.fusion=rrf'")
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(qdrantQueryResponse{
|
||||
Result: []qdrantSearchHit{
|
||||
{
|
||||
ID: "1",
|
||||
Score: 0.88,
|
||||
Payload: map[string]interface{}{
|
||||
"chunk_text": "Hybrid result",
|
||||
"regulation_id": "eu_2016_679",
|
||||
"regulation_name_de": "DSGVO",
|
||||
"regulation_short": "DSGVO",
|
||||
"category": "regulation",
|
||||
"source": "https://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: should not reach dense search
|
||||
t.Error("Unexpected dense search call when hybrid succeeded")
|
||||
json.NewEncoder(w).Encode(qdrantSearchResponse{Result: []qdrantSearchHit{}})
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
ollamaURL: ollamaMock.URL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: true,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
results, err := client.Search(context.Background(), "DSGVO Art. 35", nil, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Hybrid search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Text != "Hybrid result" {
|
||||
t.Errorf("Expected 'Hybrid result', got '%s'", results[0].Text)
|
||||
}
|
||||
|
||||
// Verify text index was created
|
||||
hasIndex := false
|
||||
hasQuery := false
|
||||
for _, p := range requestedPaths {
|
||||
if strings.Contains(p, "/index") {
|
||||
hasIndex = true
|
||||
}
|
||||
if strings.Contains(p, "/points/query") {
|
||||
hasQuery = true
|
||||
}
|
||||
}
|
||||
if !hasIndex {
|
||||
t.Error("Expected text index creation call")
|
||||
}
|
||||
if !hasQuery {
|
||||
t.Error("Expected Query API call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridSearch_FallbackToDense(t *testing.T) {
|
||||
var requestedPaths []string
|
||||
|
||||
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{
|
||||
Embedding: make([]float64, 1024),
|
||||
})
|
||||
}))
|
||||
defer ollamaMock.Close()
|
||||
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPaths = append(requestedPaths, r.URL.Path)
|
||||
|
||||
if strings.Contains(r.URL.Path, "/index") {
|
||||
// Simulate text index failure (old Qdrant version)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"status":{"error":"not supported"}}`))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(r.URL.Path, "/points/search") {
|
||||
// Dense fallback
|
||||
json.NewEncoder(w).Encode(qdrantSearchResponse{
|
||||
Result: []qdrantSearchHit{
|
||||
{
|
||||
ID: "2",
|
||||
Score: 0.90,
|
||||
Payload: map[string]interface{}{
|
||||
"chunk_text": "Dense fallback result",
|
||||
"regulation_id": "eu_2016_679",
|
||||
"regulation_name_de": "DSGVO",
|
||||
"regulation_short": "DSGVO",
|
||||
"category": "regulation",
|
||||
"source": "https://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
ollamaURL: ollamaMock.URL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: true,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
results, err := client.Search(context.Background(), "test query", nil, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Fallback search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 result, got %d", len(results))
|
||||
}
|
||||
if results[0].Text != "Dense fallback result" {
|
||||
t.Errorf("Expected 'Dense fallback result', got '%s'", results[0].Text)
|
||||
}
|
||||
|
||||
// Verify it fell back to dense search
|
||||
hasDense := false
|
||||
for _, p := range requestedPaths {
|
||||
if strings.Contains(p, "/points/search") {
|
||||
hasDense = true
|
||||
}
|
||||
}
|
||||
if !hasDense {
|
||||
t.Error("Expected fallback to dense /points/search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureTextIndex_OnlyCalledOnce(t *testing.T) {
|
||||
callCount := 0
|
||||
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/index") {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"result":{"operation_id":1,"status":"completed"}}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"result":[]}`))
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_ = client.ensureTextIndex(ctx, "test_collection")
|
||||
_ = client.ensureTextIndex(ctx, "test_collection")
|
||||
_ = client.ensureTextIndex(ctx, "test_collection")
|
||||
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected ensureTextIndex to call Qdrant exactly once, called %d times", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridDisabled_UsesDenseOnly(t *testing.T) {
|
||||
var requestedPaths []string
|
||||
|
||||
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{
|
||||
Embedding: make([]float64, 1024),
|
||||
})
|
||||
}))
|
||||
defer ollamaMock.Close()
|
||||
|
||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPaths = append(requestedPaths, r.URL.Path)
|
||||
json.NewEncoder(w).Encode(qdrantSearchResponse{
|
||||
Result: []qdrantSearchHit{},
|
||||
})
|
||||
}))
|
||||
defer qdrantMock.Close()
|
||||
|
||||
client := &LegalRAGClient{
|
||||
qdrantURL: qdrantMock.URL,
|
||||
ollamaURL: ollamaMock.URL,
|
||||
embeddingModel: "bge-m3",
|
||||
collection: "bp_compliance_ce",
|
||||
textIndexEnsured: make(map[string]bool),
|
||||
hybridEnabled: false,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
_, err := client.Search(context.Background(), "test", nil, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
for _, p := range requestedPaths {
|
||||
if strings.Contains(p, "/points/query") {
|
||||
t.Error("Query API should not be called when hybrid is disabled")
|
||||
}
|
||||
if strings.Contains(p, "/index") {
|
||||
t.Error("Text index should not be created when hybrid is disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,10 +217,221 @@ type UseCaseIntake struct {
|
||||
// Only applicable for financial domains (banking, finance, insurance, investment)
|
||||
FinancialContext *FinancialContext `json:"financial_context,omitempty"`
|
||||
|
||||
// BetrVG / works council context (Germany)
|
||||
EmployeeMonitoring bool `json:"employee_monitoring,omitempty"` // System can monitor employee behavior/performance
|
||||
HRDecisionSupport bool `json:"hr_decision_support,omitempty"` // System supports HR decisions (hiring, evaluation, termination)
|
||||
WorksCouncilConsulted bool `json:"works_council_consulted,omitempty"` // Works council has been consulted
|
||||
|
||||
// Domain-specific contexts (AI Act Annex III high-risk domains)
|
||||
HRContext *HRContext `json:"hr_context,omitempty"`
|
||||
EducationContext *EducationContext `json:"education_context,omitempty"`
|
||||
HealthcareContext *HealthcareContext `json:"healthcare_context,omitempty"`
|
||||
LegalDomainContext *LegalDomainContext `json:"legal_context,omitempty"`
|
||||
PublicSectorContext *PublicSectorContext `json:"public_sector_context,omitempty"`
|
||||
CriticalInfraContext *CriticalInfraContext `json:"critical_infra_context,omitempty"`
|
||||
AutomotiveContext *AutomotiveContext `json:"automotive_context,omitempty"`
|
||||
RetailContext *RetailContext `json:"retail_context,omitempty"`
|
||||
ITSecurityContext *ITSecurityContext `json:"it_security_context,omitempty"`
|
||||
LogisticsContext *LogisticsContext `json:"logistics_context,omitempty"`
|
||||
ConstructionContext *ConstructionContext `json:"construction_context,omitempty"`
|
||||
MarketingContext *MarketingContext `json:"marketing_context,omitempty"`
|
||||
ManufacturingContext *ManufacturingContext `json:"manufacturing_context,omitempty"`
|
||||
AgricultureContext *AgricultureContext `json:"agriculture_context,omitempty"`
|
||||
SocialServicesCtx *SocialServicesContext `json:"social_services_context,omitempty"`
|
||||
HospitalityContext *HospitalityContext `json:"hospitality_context,omitempty"`
|
||||
InsuranceContext *InsuranceContext `json:"insurance_context,omitempty"`
|
||||
InvestmentContext *InvestmentContext `json:"investment_context,omitempty"`
|
||||
DefenseContext *DefenseContext `json:"defense_context,omitempty"`
|
||||
SupplyChainContext *SupplyChainContext `json:"supply_chain_context,omitempty"`
|
||||
FacilityContext *FacilityContext `json:"facility_context,omitempty"`
|
||||
SportsContext *SportsContext `json:"sports_context,omitempty"`
|
||||
|
||||
// Opt-in to store raw text (otherwise only hash)
|
||||
StoreRawText bool `json:"store_raw_text,omitempty"`
|
||||
}
|
||||
|
||||
// HRContext captures HR/recruiting-specific compliance data (AI Act Annex III Nr. 4 + AGG)
|
||||
type HRContext struct {
|
||||
AutomatedScreening bool `json:"automated_screening"` // KI sortiert Bewerber vor
|
||||
AutomatedRejection bool `json:"automated_rejection"` // KI generiert Absagen
|
||||
CandidateRanking bool `json:"candidate_ranking"` // KI erstellt Bewerber-Rankings
|
||||
BiasAuditsDone bool `json:"bias_audits_done"` // Regelmaessige Bias-Audits
|
||||
AGGCategoriesVisible bool `json:"agg_categories_visible"` // System kann Name/Foto/Alter erkennen
|
||||
HumanReviewEnforced bool `json:"human_review_enforced"` // Mensch prueft jede KI-Empfehlung
|
||||
PerformanceEvaluation bool `json:"performance_evaluation"` // KI bewertet Mitarbeiterleistung
|
||||
}
|
||||
|
||||
// EducationContext captures education-specific compliance data (AI Act Annex III Nr. 3)
|
||||
type EducationContext struct {
|
||||
GradeInfluence bool `json:"grade_influence"` // KI beeinflusst Noten
|
||||
ExamEvaluation bool `json:"exam_evaluation"` // KI bewertet Pruefungen
|
||||
StudentSelection bool `json:"student_selection"` // KI beeinflusst Zugang/Auswahl
|
||||
MinorsInvolved bool `json:"minors_involved"` // Minderjaehrige betroffen
|
||||
TeacherReviewRequired bool `json:"teacher_review_required"` // Lehrkraft prueft KI-Ergebnis
|
||||
LearningAdaptation bool `json:"learning_adaptation"` // KI passt Lernpfade an
|
||||
}
|
||||
|
||||
// HealthcareContext captures healthcare-specific compliance data (AI Act Annex III Nr. 5 + MDR)
|
||||
type HealthcareContext struct {
|
||||
DiagnosisSupport bool `json:"diagnosis_support"` // KI unterstuetzt Diagnosen
|
||||
TreatmentRecommend bool `json:"treatment_recommendation"` // KI empfiehlt Behandlungen
|
||||
TriageDecision bool `json:"triage_decision"` // KI priorisiert Patienten
|
||||
PatientDataProcessed bool `json:"patient_data_processed"` // Gesundheitsdaten verarbeitet
|
||||
MedicalDevice bool `json:"medical_device"` // System ist Medizinprodukt
|
||||
ClinicalValidation bool `json:"clinical_validation"` // Klinisch validiert
|
||||
}
|
||||
|
||||
// LegalDomainContext captures legal/justice-specific compliance data (AI Act Annex III Nr. 8)
|
||||
type LegalDomainContext struct {
|
||||
LegalAdvice bool `json:"legal_advice"` // KI gibt Rechtsberatung
|
||||
ContractAnalysis bool `json:"contract_analysis"` // KI analysiert Vertraege
|
||||
CourtPrediction bool `json:"court_prediction"` // KI prognostiziert Urteile
|
||||
AccessToJustice bool `json:"access_to_justice"` // KI beeinflusst Zugang zu Recht
|
||||
ClientConfidential bool `json:"client_confidential"` // Mandantengeheimnis betroffen
|
||||
}
|
||||
|
||||
// PublicSectorContext captures public sector compliance data (Art. 27 FRIA)
|
||||
type PublicSectorContext struct {
|
||||
AdminDecision bool `json:"admin_decision"` // KI beeinflusst Verwaltungsentscheidungen
|
||||
CitizenService bool `json:"citizen_service"` // KI in Buergerservices
|
||||
BenefitAllocation bool `json:"benefit_allocation"` // KI verteilt Leistungen/Mittel
|
||||
PublicSafety bool `json:"public_safety"` // KI in oeffentlicher Sicherheit
|
||||
TransparencyEnsured bool `json:"transparency_ensured"` // Transparenz gegenueber Buergern
|
||||
}
|
||||
|
||||
// CriticalInfraContext captures critical infrastructure data (NIS2 + Annex III Nr. 2)
|
||||
type CriticalInfraContext struct {
|
||||
GridControl bool `json:"grid_control"` // KI steuert Netz/Infrastruktur
|
||||
SafetyCritical bool `json:"safety_critical"` // Sicherheitskritische Steuerung
|
||||
AnomalyDetection bool `json:"anomaly_detection"` // KI erkennt Anomalien
|
||||
RedundancyExists bool `json:"redundancy_exists"` // Redundante Systeme vorhanden
|
||||
IncidentResponse bool `json:"incident_response"` // Incident Response Plan vorhanden
|
||||
}
|
||||
|
||||
// AutomotiveContext captures automotive/aerospace safety data
|
||||
type AutomotiveContext struct {
|
||||
AutonomousDriving bool `json:"autonomous_driving"` // Autonomes Fahren / ADAS
|
||||
SafetyRelevant bool `json:"safety_relevant"` // Sicherheitsrelevante Funktion
|
||||
TypeApprovalNeeded bool `json:"type_approval_needed"` // Typgenehmigung erforderlich
|
||||
FunctionalSafety bool `json:"functional_safety"` // ISO 26262 relevant
|
||||
}
|
||||
|
||||
// RetailContext captures retail/e-commerce compliance data
|
||||
type RetailContext struct {
|
||||
PricingPersonalized bool `json:"pricing_personalized"` // Personalisierte Preise
|
||||
CustomerProfiling bool `json:"customer_profiling"` // Kundenprofilbildung
|
||||
RecommendationEngine bool `json:"recommendation_engine"` // Empfehlungssystem
|
||||
CreditScoring bool `json:"credit_scoring"` // Bonitaetspruefung bei Kauf
|
||||
DarkPatterns bool `json:"dark_patterns"` // Manipulative UI-Muster moeglich
|
||||
}
|
||||
|
||||
// ITSecurityContext captures IT/cybersecurity/telecom data
|
||||
type ITSecurityContext struct {
|
||||
EmployeeSurveillance bool `json:"employee_surveillance"` // Mitarbeiterueberwachung
|
||||
NetworkMonitoring bool `json:"network_monitoring"` // Netzwerkueberwachung
|
||||
ThreatDetection bool `json:"threat_detection"` // Bedrohungserkennung
|
||||
AccessControl bool `json:"access_control_ai"` // KI-gestuetzte Zugriffskontrolle
|
||||
DataRetention bool `json:"data_retention_logs"` // Umfangreiche Log-Speicherung
|
||||
}
|
||||
|
||||
// LogisticsContext captures logistics/transport compliance data
|
||||
type LogisticsContext struct {
|
||||
DriverTracking bool `json:"driver_tracking"` // Fahrer-/Kurier-Tracking
|
||||
RouteOptimization bool `json:"route_optimization"` // Routenoptimierung mit Personenbezug
|
||||
WorkloadScoring bool `json:"workload_scoring"` // Leistungsbewertung Lagerarbeiter
|
||||
PredictiveMaint bool `json:"predictive_maintenance"` // Vorausschauende Wartung
|
||||
}
|
||||
|
||||
// ConstructionContext captures construction/real estate data
|
||||
type ConstructionContext struct {
|
||||
SafetyMonitoring bool `json:"safety_monitoring"` // Baustellensicherheit per KI
|
||||
TenantScreening bool `json:"tenant_screening"` // KI-gestuetzte Mieterauswahl
|
||||
BuildingAutomation bool `json:"building_automation"` // Gebaeudesteuerung
|
||||
WorkerSafety bool `json:"worker_safety"` // Arbeitsschutzueberwachung
|
||||
}
|
||||
|
||||
// MarketingContext captures marketing/media compliance data
|
||||
type MarketingContext struct {
|
||||
DeepfakeContent bool `json:"deepfake_content"` // Synthetische Inhalte (Deepfakes)
|
||||
ContentModeration bool `json:"content_moderation"` // Automatische Inhaltsmoderation
|
||||
BehavioralTargeting bool `json:"behavioral_targeting"` // Verhaltensbasiertes Targeting
|
||||
MinorsTargeted bool `json:"minors_targeted"` // Minderjaehrige als Zielgruppe
|
||||
AIContentLabeled bool `json:"ai_content_labeled"` // KI-Inhalte als solche gekennzeichnet
|
||||
}
|
||||
|
||||
// ManufacturingContext captures manufacturing/CE safety data
|
||||
type ManufacturingContext struct {
|
||||
MachineSafety bool `json:"machine_safety"` // Maschinensicherheit
|
||||
QualityControl bool `json:"quality_control"` // KI in Qualitaetskontrolle
|
||||
ProcessControl bool `json:"process_control"` // KI steuert Fertigungsprozess
|
||||
CEMarkingRequired bool `json:"ce_marking_required"` // CE-Kennzeichnung erforderlich
|
||||
SafetyValidated bool `json:"safety_validated"` // Sicherheitsvalidierung durchgefuehrt
|
||||
}
|
||||
|
||||
// AgricultureContext captures agriculture/forestry compliance data
|
||||
type AgricultureContext struct {
|
||||
PesticideAI bool `json:"pesticide_ai"` // KI steuert Pestizideinsatz
|
||||
AnimalWelfare bool `json:"animal_welfare"` // KI beeinflusst Tierhaltung
|
||||
EnvironmentalData bool `json:"environmental_data"` // Umweltdaten verarbeitet
|
||||
}
|
||||
|
||||
// SocialServicesContext captures social services/nonprofit data
|
||||
type SocialServicesContext struct {
|
||||
VulnerableGroups bool `json:"vulnerable_groups"` // Schutzbeduerftiger Personenkreis
|
||||
BenefitDecision bool `json:"benefit_decision"` // KI beeinflusst Leistungszuteilung
|
||||
CaseManagement bool `json:"case_management"` // KI in Fallmanagement
|
||||
}
|
||||
|
||||
// HospitalityContext captures hospitality/tourism data
|
||||
type HospitalityContext struct {
|
||||
GuestProfiling bool `json:"guest_profiling"` // Gaeste-Profilbildung
|
||||
DynamicPricing bool `json:"dynamic_pricing"` // Dynamische Preisgestaltung
|
||||
ReviewManipulation bool `json:"review_manipulation"` // KI beeinflusst Bewertungen
|
||||
}
|
||||
|
||||
// InsuranceContext captures insurance-specific data (beyond FinancialContext)
|
||||
type InsuranceContext struct {
|
||||
RiskClassification bool `json:"risk_classification"` // KI klassifiziert Versicherungsrisiken
|
||||
ClaimsAutomation bool `json:"claims_automation"` // Automatisierte Schadenbearbeitung
|
||||
PremiumCalculation bool `json:"premium_calculation"` // KI berechnet Praemien individuell
|
||||
FraudDetection bool `json:"fraud_detection"` // Betrugserkennung
|
||||
}
|
||||
|
||||
// InvestmentContext captures investment-specific data
|
||||
type InvestmentContext struct {
|
||||
AlgoTrading bool `json:"algo_trading"` // Algorithmischer Handel
|
||||
InvestmentAdvice bool `json:"investment_advice"` // KI-gestuetzte Anlageberatung
|
||||
RoboAdvisor bool `json:"robo_advisor"` // Automatisierte Vermoegensberatung
|
||||
}
|
||||
|
||||
// DefenseContext captures defense/dual-use data
|
||||
type DefenseContext struct {
|
||||
DualUse bool `json:"dual_use"` // Dual-Use Technologie
|
||||
ExportControlled bool `json:"export_controlled"` // Exportkontrolle relevant
|
||||
ClassifiedData bool `json:"classified_data"` // Verschlusssachen verarbeitet
|
||||
}
|
||||
|
||||
// SupplyChainContext captures textile/packaging/supply chain data (LkSG)
|
||||
type SupplyChainContext struct {
|
||||
SupplierMonitoring bool `json:"supplier_monitoring"` // KI ueberwacht Lieferanten
|
||||
HumanRightsCheck bool `json:"human_rights_check"` // Menschenrechtspruefung in Lieferkette
|
||||
EnvironmentalImpact bool `json:"environmental_impact"` // Umweltauswirkungen analysiert
|
||||
}
|
||||
|
||||
// FacilityContext captures facility management data
|
||||
type FacilityContext struct {
|
||||
AccessControlAI bool `json:"access_control_ai"` // KI-Zutrittskontrolle
|
||||
OccupancyTracking bool `json:"occupancy_tracking"` // Belegungsueberwachung
|
||||
EnergyOptimization bool `json:"energy_optimization"` // Energieoptimierung
|
||||
}
|
||||
|
||||
// SportsContext captures sports/general data
|
||||
type SportsContext struct {
|
||||
AthleteTracking bool `json:"athlete_tracking"` // Athleten-Performance-Tracking
|
||||
FanProfiling bool `json:"fan_profiling"` // Fan-/Zuschauer-Profilbildung
|
||||
DopingDetection bool `json:"doping_detection"` // KI in Doping-Kontrolle
|
||||
}
|
||||
|
||||
// DataTypes specifies what kinds of data are processed
|
||||
type DataTypes struct {
|
||||
PersonalData bool `json:"personal_data"`
|
||||
@@ -383,6 +594,13 @@ type AssessmentResult struct {
|
||||
Art22Risk bool `json:"art22_risk"` // Art. 22 GDPR automated decision risk
|
||||
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
||||
|
||||
// BetrVG Conflict Score (0-100) — works council escalation risk
|
||||
BetrvgConflictScore int `json:"betrvg_conflict_score"`
|
||||
BetrvgConsultationRequired bool `json:"betrvg_consultation_required"`
|
||||
|
||||
// Input (needed for escalation logic)
|
||||
Intake UseCaseIntake `json:"-"` // not serialized, internal use only
|
||||
|
||||
// Summary for humans
|
||||
Summary string `json:"summary"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
@@ -471,6 +689,10 @@ type Assessment struct {
|
||||
Art22Risk bool `json:"art22_risk"`
|
||||
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
||||
|
||||
// BetrVG Conflict Score (0-100) — works council escalation risk
|
||||
BetrvgConflictScore int `json:"betrvg_conflict_score"`
|
||||
BetrvgConsultationRequired bool `json:"betrvg_consultation_required"`
|
||||
|
||||
// Corpus Versioning (RAG)
|
||||
CorpusVersionID *uuid.UUID `json:"corpus_version_id,omitempty"`
|
||||
CorpusVersion string `json:"corpus_version,omitempty"`
|
||||
@@ -525,3 +747,73 @@ const (
|
||||
ExportFormatJSON ExportFormat = "json"
|
||||
ExportFormatMarkdown ExportFormat = "md"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// AI Act Decision Tree Types
|
||||
// ============================================================================
|
||||
|
||||
// GPAICategory represents the GPAI classification result
|
||||
type GPAICategory string
|
||||
|
||||
const (
|
||||
GPAICategoryNone GPAICategory = "none"
|
||||
GPAICategoryStandard GPAICategory = "standard"
|
||||
GPAICategorySystemic GPAICategory = "systemic"
|
||||
)
|
||||
|
||||
// GPAIClassification represents the result of the GPAI axis evaluation
|
||||
type GPAIClassification struct {
|
||||
IsGPAI bool `json:"is_gpai"`
|
||||
IsSystemicRisk bool `json:"is_systemic_risk"`
|
||||
Category GPAICategory `json:"gpai_category"`
|
||||
ApplicableArticles []string `json:"applicable_articles"`
|
||||
Obligations []string `json:"obligations"`
|
||||
}
|
||||
|
||||
// DecisionTreeAnswer represents a user's answer to a decision tree question
|
||||
type DecisionTreeAnswer struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
Value bool `json:"value"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionTreeQuestion represents a single question in the decision tree
|
||||
type DecisionTreeQuestion struct {
|
||||
ID string `json:"id"`
|
||||
Axis string `json:"axis"` // "high_risk" or "gpai"
|
||||
Question string `json:"question"`
|
||||
Description string `json:"description"` // Additional context
|
||||
ArticleRef string `json:"article_ref"` // e.g., "Art. 5", "Anhang III"
|
||||
SkipIf string `json:"skip_if,omitempty"` // Question ID — skip if that was answered "no"
|
||||
}
|
||||
|
||||
// DecisionTreeDefinition represents the full decision tree structure for the frontend
|
||||
type DecisionTreeDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Questions []DecisionTreeQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
// DecisionTreeEvalRequest is the API request for evaluating the decision tree
|
||||
type DecisionTreeEvalRequest struct {
|
||||
SystemName string `json:"system_name"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
Answers map[string]DecisionTreeAnswer `json:"answers"`
|
||||
}
|
||||
|
||||
// DecisionTreeResult represents the combined evaluation result
|
||||
type DecisionTreeResult struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SystemName string `json:"system_name"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
Answers map[string]DecisionTreeAnswer `json:"answers"`
|
||||
HighRiskResult AIActRiskLevel `json:"high_risk_result"`
|
||||
GPAIResult GPAIClassification `json:"gpai_result"`
|
||||
CombinedObligations []string `json:"combined_obligations"`
|
||||
ApplicableArticles []string `json:"applicable_articles"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult {
|
||||
RiskLevel: RiskLevelMINIMAL,
|
||||
Complexity: ComplexityLOW,
|
||||
RiskScore: 0,
|
||||
Intake: *intake,
|
||||
TriggeredRules: []TriggeredRule{},
|
||||
RequiredControls: []RequiredControl{},
|
||||
RecommendedArchitecture: []PatternRecommendation{},
|
||||
@@ -338,6 +339,9 @@ func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult {
|
||||
// Determine complexity
|
||||
result.Complexity = e.calculateComplexity(result)
|
||||
|
||||
// Calculate BetrVG Conflict Score (Germany only, employees >= 5)
|
||||
result.BetrvgConflictScore, result.BetrvgConsultationRequired = e.calculateBetrvgConflictScore(intake)
|
||||
|
||||
// Check if DSFA is recommended
|
||||
result.DSFARecommended = e.shouldRecommendDSFA(intake, result)
|
||||
|
||||
@@ -457,11 +461,382 @@ func (e *PolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interf
|
||||
return nil
|
||||
}
|
||||
return e.getRetentionValue(parts[1], intake)
|
||||
case "employee_monitoring":
|
||||
return intake.EmployeeMonitoring
|
||||
case "hr_decision_support":
|
||||
return intake.HRDecisionSupport
|
||||
case "works_council_consulted":
|
||||
return intake.WorksCouncilConsulted
|
||||
case "hr_context":
|
||||
if len(parts) < 2 || intake.HRContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getHRContextValue(parts[1], intake)
|
||||
case "education_context":
|
||||
if len(parts) < 2 || intake.EducationContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getEducationContextValue(parts[1], intake)
|
||||
case "healthcare_context":
|
||||
if len(parts) < 2 || intake.HealthcareContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getHealthcareContextValue(parts[1], intake)
|
||||
case "legal_context":
|
||||
if len(parts) < 2 || intake.LegalDomainContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getLegalContextValue(parts[1], intake)
|
||||
case "public_sector_context":
|
||||
if len(parts) < 2 || intake.PublicSectorContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getPublicSectorContextValue(parts[1], intake)
|
||||
case "critical_infra_context":
|
||||
if len(parts) < 2 || intake.CriticalInfraContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getCriticalInfraContextValue(parts[1], intake)
|
||||
case "automotive_context":
|
||||
if len(parts) < 2 || intake.AutomotiveContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getAutomotiveContextValue(parts[1], intake)
|
||||
case "retail_context":
|
||||
if len(parts) < 2 || intake.RetailContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getRetailContextValue(parts[1], intake)
|
||||
case "it_security_context":
|
||||
if len(parts) < 2 || intake.ITSecurityContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getITSecurityContextValue(parts[1], intake)
|
||||
case "logistics_context":
|
||||
if len(parts) < 2 || intake.LogisticsContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getLogisticsContextValue(parts[1], intake)
|
||||
case "construction_context":
|
||||
if len(parts) < 2 || intake.ConstructionContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getConstructionContextValue(parts[1], intake)
|
||||
case "marketing_context":
|
||||
if len(parts) < 2 || intake.MarketingContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getMarketingContextValue(parts[1], intake)
|
||||
case "manufacturing_context":
|
||||
if len(parts) < 2 || intake.ManufacturingContext == nil {
|
||||
return nil
|
||||
}
|
||||
return e.getManufacturingContextValue(parts[1], intake)
|
||||
case "agriculture_context":
|
||||
if len(parts) < 2 || intake.AgricultureContext == nil { return nil }
|
||||
return e.getAgricultureContextValue(parts[1], intake)
|
||||
case "social_services_context":
|
||||
if len(parts) < 2 || intake.SocialServicesCtx == nil { return nil }
|
||||
return e.getSocialServicesContextValue(parts[1], intake)
|
||||
case "hospitality_context":
|
||||
if len(parts) < 2 || intake.HospitalityContext == nil { return nil }
|
||||
return e.getHospitalityContextValue(parts[1], intake)
|
||||
case "insurance_context":
|
||||
if len(parts) < 2 || intake.InsuranceContext == nil { return nil }
|
||||
return e.getInsuranceContextValue(parts[1], intake)
|
||||
case "investment_context":
|
||||
if len(parts) < 2 || intake.InvestmentContext == nil { return nil }
|
||||
return e.getInvestmentContextValue(parts[1], intake)
|
||||
case "defense_context":
|
||||
if len(parts) < 2 || intake.DefenseContext == nil { return nil }
|
||||
return e.getDefenseContextValue(parts[1], intake)
|
||||
case "supply_chain_context":
|
||||
if len(parts) < 2 || intake.SupplyChainContext == nil { return nil }
|
||||
return e.getSupplyChainContextValue(parts[1], intake)
|
||||
case "facility_context":
|
||||
if len(parts) < 2 || intake.FacilityContext == nil { return nil }
|
||||
return e.getFacilityContextValue(parts[1], intake)
|
||||
case "sports_context":
|
||||
if len(parts) < 2 || intake.SportsContext == nil { return nil }
|
||||
return e.getSportsContextValue(parts[1], intake)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getHRContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.HRContext == nil {
|
||||
return nil
|
||||
}
|
||||
switch field {
|
||||
case "automated_screening":
|
||||
return intake.HRContext.AutomatedScreening
|
||||
case "automated_rejection":
|
||||
return intake.HRContext.AutomatedRejection
|
||||
case "candidate_ranking":
|
||||
return intake.HRContext.CandidateRanking
|
||||
case "bias_audits_done":
|
||||
return intake.HRContext.BiasAuditsDone
|
||||
case "agg_categories_visible":
|
||||
return intake.HRContext.AGGCategoriesVisible
|
||||
case "human_review_enforced":
|
||||
return intake.HRContext.HumanReviewEnforced
|
||||
case "performance_evaluation":
|
||||
return intake.HRContext.PerformanceEvaluation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getEducationContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.EducationContext == nil {
|
||||
return nil
|
||||
}
|
||||
switch field {
|
||||
case "grade_influence":
|
||||
return intake.EducationContext.GradeInfluence
|
||||
case "exam_evaluation":
|
||||
return intake.EducationContext.ExamEvaluation
|
||||
case "student_selection":
|
||||
return intake.EducationContext.StudentSelection
|
||||
case "minors_involved":
|
||||
return intake.EducationContext.MinorsInvolved
|
||||
case "teacher_review_required":
|
||||
return intake.EducationContext.TeacherReviewRequired
|
||||
case "learning_adaptation":
|
||||
return intake.EducationContext.LearningAdaptation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getHealthcareContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.HealthcareContext == nil {
|
||||
return nil
|
||||
}
|
||||
switch field {
|
||||
case "diagnosis_support":
|
||||
return intake.HealthcareContext.DiagnosisSupport
|
||||
case "treatment_recommendation":
|
||||
return intake.HealthcareContext.TreatmentRecommend
|
||||
case "triage_decision":
|
||||
return intake.HealthcareContext.TriageDecision
|
||||
case "patient_data_processed":
|
||||
return intake.HealthcareContext.PatientDataProcessed
|
||||
case "medical_device":
|
||||
return intake.HealthcareContext.MedicalDevice
|
||||
case "clinical_validation":
|
||||
return intake.HealthcareContext.ClinicalValidation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getLegalContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.LegalDomainContext == nil { return nil }
|
||||
switch field {
|
||||
case "legal_advice": return intake.LegalDomainContext.LegalAdvice
|
||||
case "contract_analysis": return intake.LegalDomainContext.ContractAnalysis
|
||||
case "court_prediction": return intake.LegalDomainContext.CourtPrediction
|
||||
case "access_to_justice": return intake.LegalDomainContext.AccessToJustice
|
||||
case "client_confidential": return intake.LegalDomainContext.ClientConfidential
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getPublicSectorContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.PublicSectorContext == nil { return nil }
|
||||
switch field {
|
||||
case "admin_decision": return intake.PublicSectorContext.AdminDecision
|
||||
case "citizen_service": return intake.PublicSectorContext.CitizenService
|
||||
case "benefit_allocation": return intake.PublicSectorContext.BenefitAllocation
|
||||
case "public_safety": return intake.PublicSectorContext.PublicSafety
|
||||
case "transparency_ensured": return intake.PublicSectorContext.TransparencyEnsured
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getCriticalInfraContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.CriticalInfraContext == nil { return nil }
|
||||
switch field {
|
||||
case "grid_control": return intake.CriticalInfraContext.GridControl
|
||||
case "safety_critical": return intake.CriticalInfraContext.SafetyCritical
|
||||
case "anomaly_detection": return intake.CriticalInfraContext.AnomalyDetection
|
||||
case "redundancy_exists": return intake.CriticalInfraContext.RedundancyExists
|
||||
case "incident_response": return intake.CriticalInfraContext.IncidentResponse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getAutomotiveContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.AutomotiveContext == nil { return nil }
|
||||
switch field {
|
||||
case "autonomous_driving": return intake.AutomotiveContext.AutonomousDriving
|
||||
case "safety_relevant": return intake.AutomotiveContext.SafetyRelevant
|
||||
case "type_approval_needed": return intake.AutomotiveContext.TypeApprovalNeeded
|
||||
case "functional_safety": return intake.AutomotiveContext.FunctionalSafety
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getRetailContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.RetailContext == nil { return nil }
|
||||
switch field {
|
||||
case "pricing_personalized": return intake.RetailContext.PricingPersonalized
|
||||
case "customer_profiling": return intake.RetailContext.CustomerProfiling
|
||||
case "recommendation_engine": return intake.RetailContext.RecommendationEngine
|
||||
case "credit_scoring": return intake.RetailContext.CreditScoring
|
||||
case "dark_patterns": return intake.RetailContext.DarkPatterns
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getITSecurityContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.ITSecurityContext == nil { return nil }
|
||||
switch field {
|
||||
case "employee_surveillance": return intake.ITSecurityContext.EmployeeSurveillance
|
||||
case "network_monitoring": return intake.ITSecurityContext.NetworkMonitoring
|
||||
case "threat_detection": return intake.ITSecurityContext.ThreatDetection
|
||||
case "access_control_ai": return intake.ITSecurityContext.AccessControl
|
||||
case "data_retention_logs": return intake.ITSecurityContext.DataRetention
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getLogisticsContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.LogisticsContext == nil { return nil }
|
||||
switch field {
|
||||
case "driver_tracking": return intake.LogisticsContext.DriverTracking
|
||||
case "route_optimization": return intake.LogisticsContext.RouteOptimization
|
||||
case "workload_scoring": return intake.LogisticsContext.WorkloadScoring
|
||||
case "predictive_maintenance": return intake.LogisticsContext.PredictiveMaint
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getConstructionContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.ConstructionContext == nil { return nil }
|
||||
switch field {
|
||||
case "safety_monitoring": return intake.ConstructionContext.SafetyMonitoring
|
||||
case "tenant_screening": return intake.ConstructionContext.TenantScreening
|
||||
case "building_automation": return intake.ConstructionContext.BuildingAutomation
|
||||
case "worker_safety": return intake.ConstructionContext.WorkerSafety
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getMarketingContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.MarketingContext == nil { return nil }
|
||||
switch field {
|
||||
case "deepfake_content": return intake.MarketingContext.DeepfakeContent
|
||||
case "content_moderation": return intake.MarketingContext.ContentModeration
|
||||
case "behavioral_targeting": return intake.MarketingContext.BehavioralTargeting
|
||||
case "minors_targeted": return intake.MarketingContext.MinorsTargeted
|
||||
case "ai_content_labeled": return intake.MarketingContext.AIContentLabeled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getManufacturingContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.ManufacturingContext == nil { return nil }
|
||||
switch field {
|
||||
case "machine_safety": return intake.ManufacturingContext.MachineSafety
|
||||
case "quality_control": return intake.ManufacturingContext.QualityControl
|
||||
case "process_control": return intake.ManufacturingContext.ProcessControl
|
||||
case "ce_marking_required": return intake.ManufacturingContext.CEMarkingRequired
|
||||
case "safety_validated": return intake.ManufacturingContext.SafetyValidated
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getAgricultureContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.AgricultureContext == nil { return nil }
|
||||
switch field {
|
||||
case "pesticide_ai": return intake.AgricultureContext.PesticideAI
|
||||
case "animal_welfare": return intake.AgricultureContext.AnimalWelfare
|
||||
case "environmental_data": return intake.AgricultureContext.EnvironmentalData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getSocialServicesContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.SocialServicesCtx == nil { return nil }
|
||||
switch field {
|
||||
case "vulnerable_groups": return intake.SocialServicesCtx.VulnerableGroups
|
||||
case "benefit_decision": return intake.SocialServicesCtx.BenefitDecision
|
||||
case "case_management": return intake.SocialServicesCtx.CaseManagement
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getHospitalityContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.HospitalityContext == nil { return nil }
|
||||
switch field {
|
||||
case "guest_profiling": return intake.HospitalityContext.GuestProfiling
|
||||
case "dynamic_pricing": return intake.HospitalityContext.DynamicPricing
|
||||
case "review_manipulation": return intake.HospitalityContext.ReviewManipulation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getInsuranceContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.InsuranceContext == nil { return nil }
|
||||
switch field {
|
||||
case "risk_classification": return intake.InsuranceContext.RiskClassification
|
||||
case "claims_automation": return intake.InsuranceContext.ClaimsAutomation
|
||||
case "premium_calculation": return intake.InsuranceContext.PremiumCalculation
|
||||
case "fraud_detection": return intake.InsuranceContext.FraudDetection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getInvestmentContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.InvestmentContext == nil { return nil }
|
||||
switch field {
|
||||
case "algo_trading": return intake.InvestmentContext.AlgoTrading
|
||||
case "investment_advice": return intake.InvestmentContext.InvestmentAdvice
|
||||
case "robo_advisor": return intake.InvestmentContext.RoboAdvisor
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getDefenseContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.DefenseContext == nil { return nil }
|
||||
switch field {
|
||||
case "dual_use": return intake.DefenseContext.DualUse
|
||||
case "export_controlled": return intake.DefenseContext.ExportControlled
|
||||
case "classified_data": return intake.DefenseContext.ClassifiedData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getSupplyChainContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.SupplyChainContext == nil { return nil }
|
||||
switch field {
|
||||
case "supplier_monitoring": return intake.SupplyChainContext.SupplierMonitoring
|
||||
case "human_rights_check": return intake.SupplyChainContext.HumanRightsCheck
|
||||
case "environmental_impact": return intake.SupplyChainContext.EnvironmentalImpact
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getFacilityContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.FacilityContext == nil { return nil }
|
||||
switch field {
|
||||
case "access_control_ai": return intake.FacilityContext.AccessControlAI
|
||||
case "occupancy_tracking": return intake.FacilityContext.OccupancyTracking
|
||||
case "energy_optimization": return intake.FacilityContext.EnergyOptimization
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getSportsContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||
if intake.SportsContext == nil { return nil }
|
||||
switch field {
|
||||
case "athlete_tracking": return intake.SportsContext.AthleteTracking
|
||||
case "fan_profiling": return intake.SportsContext.FanProfiling
|
||||
case "doping_detection": return intake.SportsContext.DopingDetection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PolicyEngine) getDataTypeValue(field string, intake *UseCaseIntake) interface{} {
|
||||
switch field {
|
||||
case "personal_data":
|
||||
@@ -880,3 +1255,70 @@ func categorizeControl(id string) string {
|
||||
}
|
||||
return "organizational"
|
||||
}
|
||||
|
||||
// calculateBetrvgConflictScore computes a works council conflict score (0-100).
|
||||
// Higher score = higher risk of escalation with works council.
|
||||
// Only relevant for German organizations with >= 5 employees.
|
||||
func (e *PolicyEngine) calculateBetrvgConflictScore(intake *UseCaseIntake) (int, bool) {
|
||||
if intake.Domain == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
score := 0
|
||||
consultationRequired := false
|
||||
|
||||
// Factor 1: Employee data processing (+10)
|
||||
if intake.DataTypes.PersonalData && intake.DataTypes.EmployeeData {
|
||||
score += 10
|
||||
consultationRequired = true
|
||||
}
|
||||
|
||||
// Factor 2: System can monitor behavior/performance (+20)
|
||||
if intake.EmployeeMonitoring {
|
||||
score += 20
|
||||
consultationRequired = true
|
||||
}
|
||||
|
||||
// Factor 3: Individualized usage data / logging (+15)
|
||||
if intake.Retention.StorePrompts || intake.Retention.StoreResponses {
|
||||
score += 15
|
||||
}
|
||||
|
||||
// Factor 4: Communication analysis (+10)
|
||||
if intake.Purpose.CustomerSupport || intake.Purpose.Marketing {
|
||||
// These purposes on employee data suggest communication analysis
|
||||
if intake.DataTypes.EmployeeData {
|
||||
score += 10
|
||||
}
|
||||
}
|
||||
|
||||
// Factor 5: HR / Recruiting context (+20)
|
||||
if intake.HRDecisionSupport {
|
||||
score += 20
|
||||
consultationRequired = true
|
||||
}
|
||||
|
||||
// Factor 6: Scoring / Ranking of employees (+10)
|
||||
if intake.Outputs.RankingsOrScores || intake.Outputs.RecommendationsToUsers {
|
||||
if intake.DataTypes.EmployeeData {
|
||||
score += 10
|
||||
}
|
||||
}
|
||||
|
||||
// Factor 7: Fully automated decisions (+10)
|
||||
if intake.Automation == "fully_automated" {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Factor 8: Works council NOT consulted (+5)
|
||||
if consultationRequired && !intake.WorksCouncilConsulted {
|
||||
score += 5
|
||||
}
|
||||
|
||||
// Cap at 100
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
return score, consultationRequired
|
||||
}
|
||||
|
||||
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AIRegistration represents an EU AI Database registration entry
|
||||
type AIRegistration struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
|
||||
// System
|
||||
SystemName string `json:"system_name"`
|
||||
SystemVersion string `json:"system_version,omitempty"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
IntendedPurpose string `json:"intended_purpose,omitempty"`
|
||||
|
||||
// Provider
|
||||
ProviderName string `json:"provider_name,omitempty"`
|
||||
ProviderLegalForm string `json:"provider_legal_form,omitempty"`
|
||||
ProviderAddress string `json:"provider_address,omitempty"`
|
||||
ProviderCountry string `json:"provider_country,omitempty"`
|
||||
EURepresentativeName string `json:"eu_representative_name,omitempty"`
|
||||
EURepresentativeContact string `json:"eu_representative_contact,omitempty"`
|
||||
|
||||
// Classification
|
||||
RiskClassification string `json:"risk_classification"`
|
||||
AnnexIIICategory string `json:"annex_iii_category,omitempty"`
|
||||
GPAIClassification string `json:"gpai_classification"`
|
||||
|
||||
// Conformity
|
||||
ConformityAssessmentType string `json:"conformity_assessment_type,omitempty"`
|
||||
NotifiedBodyName string `json:"notified_body_name,omitempty"`
|
||||
NotifiedBodyID string `json:"notified_body_id,omitempty"`
|
||||
CEMarking bool `json:"ce_marking"`
|
||||
|
||||
// Training data
|
||||
TrainingDataCategories json.RawMessage `json:"training_data_categories,omitempty"`
|
||||
TrainingDataSummary string `json:"training_data_summary,omitempty"`
|
||||
|
||||
// Status
|
||||
RegistrationStatus string `json:"registration_status"`
|
||||
EUDatabaseID string `json:"eu_database_id,omitempty"`
|
||||
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||
LastUpdateDate *time.Time `json:"last_update_date,omitempty"`
|
||||
|
||||
// Links
|
||||
UCCAAssessmentID *uuid.UUID `json:"ucca_assessment_id,omitempty"`
|
||||
DecisionTreeResultID *uuid.UUID `json:"decision_tree_result_id,omitempty"`
|
||||
|
||||
// Export
|
||||
ExportData json.RawMessage `json:"export_data,omitempty"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
SubmittedBy string `json:"submitted_by,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrationStore handles AI registration persistence
|
||||
type RegistrationStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRegistrationStore creates a new registration store
|
||||
func NewRegistrationStore(pool *pgxpool.Pool) *RegistrationStore {
|
||||
return &RegistrationStore{pool: pool}
|
||||
}
|
||||
|
||||
// Create creates a new registration
|
||||
func (s *RegistrationStore) Create(ctx context.Context, r *AIRegistration) error {
|
||||
r.ID = uuid.New()
|
||||
r.CreatedAt = time.Now()
|
||||
r.UpdatedAt = time.Now()
|
||||
if r.RegistrationStatus == "" {
|
||||
r.RegistrationStatus = "draft"
|
||||
}
|
||||
if r.RiskClassification == "" {
|
||||
r.RiskClassification = "not_classified"
|
||||
}
|
||||
if r.GPAIClassification == "" {
|
||||
r.GPAIClassification = "none"
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ai_system_registrations (
|
||||
id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||
provider_name, provider_legal_form, provider_address, provider_country,
|
||||
eu_representative_name, eu_representative_contact,
|
||||
risk_classification, annex_iii_category, gpai_classification,
|
||||
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||
training_data_categories, training_data_summary,
|
||||
registration_status, ucca_assessment_id, decision_tree_result_id,
|
||||
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
|
||||
)`,
|
||||
r.ID, r.TenantID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||
r.EURepresentativeName, r.EURepresentativeContact,
|
||||
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||
r.RegistrationStatus, r.UCCAAssessmentID, r.DecisionTreeResultID,
|
||||
r.CreatedBy,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns all registrations for a tenant
|
||||
func (s *RegistrationStore) List(ctx context.Context, tenantID uuid.UUID) ([]AIRegistration, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||
provider_name, provider_legal_form, provider_address, provider_country,
|
||||
eu_representative_name, eu_representative_contact,
|
||||
risk_classification, annex_iii_category, gpai_classification,
|
||||
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||
training_data_categories, training_data_summary,
|
||||
registration_status, eu_database_id, registration_date, last_update_date,
|
||||
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||
created_at, updated_at, created_by, submitted_by
|
||||
FROM ai_system_registrations
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var registrations []AIRegistration
|
||||
for rows.Next() {
|
||||
var r AIRegistration
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registrations = append(registrations, r)
|
||||
}
|
||||
return registrations, nil
|
||||
}
|
||||
|
||||
// GetByID returns a registration by ID
|
||||
func (s *RegistrationStore) GetByID(ctx context.Context, id uuid.UUID) (*AIRegistration, error) {
|
||||
var r AIRegistration
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||
provider_name, provider_legal_form, provider_address, provider_country,
|
||||
eu_representative_name, eu_representative_contact,
|
||||
risk_classification, annex_iii_category, gpai_classification,
|
||||
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||
training_data_categories, training_data_summary,
|
||||
registration_status, eu_database_id, registration_date, last_update_date,
|
||||
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||
created_at, updated_at, created_by, submitted_by
|
||||
FROM ai_system_registrations
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(
|
||||
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// Update updates a registration
|
||||
func (s *RegistrationStore) Update(ctx context.Context, r *AIRegistration) error {
|
||||
r.UpdatedAt = time.Now()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE ai_system_registrations SET
|
||||
system_name = $2, system_version = $3, system_description = $4, intended_purpose = $5,
|
||||
provider_name = $6, provider_legal_form = $7, provider_address = $8, provider_country = $9,
|
||||
eu_representative_name = $10, eu_representative_contact = $11,
|
||||
risk_classification = $12, annex_iii_category = $13, gpai_classification = $14,
|
||||
conformity_assessment_type = $15, notified_body_name = $16, notified_body_id = $17, ce_marking = $18,
|
||||
training_data_categories = $19, training_data_summary = $20,
|
||||
registration_status = $21, eu_database_id = $22,
|
||||
export_data = $23, updated_at = $24, submitted_by = $25
|
||||
WHERE id = $1`,
|
||||
r.ID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||
r.EURepresentativeName, r.EURepresentativeContact,
|
||||
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||
r.RegistrationStatus, r.EUDatabaseID,
|
||||
r.ExportData, r.UpdatedAt, r.SubmittedBy,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStatus changes only the registration status
|
||||
func (s *RegistrationStore) UpdateStatus(ctx context.Context, id uuid.UUID, status string, submittedBy string) error {
|
||||
now := time.Now()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE ai_system_registrations
|
||||
SET registration_status = $2, submitted_by = $3, updated_at = $4,
|
||||
registration_date = CASE WHEN $2 = 'submitted' THEN $4 ELSE registration_date END,
|
||||
last_update_date = $4
|
||||
WHERE id = $1`,
|
||||
id, status, submittedBy, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// BuildExportJSON creates the EU AI Database submission JSON
|
||||
func (s *RegistrationStore) BuildExportJSON(r *AIRegistration) json.RawMessage {
|
||||
export := map[string]interface{}{
|
||||
"schema_version": "1.0",
|
||||
"submission_type": "ai_system_registration",
|
||||
"regulation": "EU AI Act (EU) 2024/1689",
|
||||
"article": "Art. 49",
|
||||
"provider": map[string]interface{}{
|
||||
"name": r.ProviderName,
|
||||
"legal_form": r.ProviderLegalForm,
|
||||
"address": r.ProviderAddress,
|
||||
"country": r.ProviderCountry,
|
||||
"eu_representative": r.EURepresentativeName,
|
||||
"eu_rep_contact": r.EURepresentativeContact,
|
||||
},
|
||||
"system": map[string]interface{}{
|
||||
"name": r.SystemName,
|
||||
"version": r.SystemVersion,
|
||||
"description": r.SystemDescription,
|
||||
"purpose": r.IntendedPurpose,
|
||||
},
|
||||
"classification": map[string]interface{}{
|
||||
"risk_level": r.RiskClassification,
|
||||
"annex_iii_category": r.AnnexIIICategory,
|
||||
"gpai": r.GPAIClassification,
|
||||
},
|
||||
"conformity": map[string]interface{}{
|
||||
"assessment_type": r.ConformityAssessmentType,
|
||||
"notified_body": r.NotifiedBodyName,
|
||||
"notified_body_id": r.NotifiedBodyID,
|
||||
"ce_marking": r.CEMarking,
|
||||
},
|
||||
"training_data": map[string]interface{}{
|
||||
"categories": r.TrainingDataCategories,
|
||||
"summary": r.TrainingDataSummary,
|
||||
},
|
||||
"status": r.RegistrationStatus,
|
||||
}
|
||||
data, _ := json.Marshal(export)
|
||||
return data
|
||||
}
|
||||
@@ -358,6 +358,128 @@ type AssessmentFilters struct {
|
||||
Offset int // OFFSET for pagination
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decision Tree Result CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateDecisionTreeResult stores a new decision tree result
|
||||
func (s *Store) CreateDecisionTreeResult(ctx context.Context, r *DecisionTreeResult) error {
|
||||
r.ID = uuid.New()
|
||||
r.CreatedAt = time.Now().UTC()
|
||||
r.UpdatedAt = r.CreatedAt
|
||||
|
||||
answers, _ := json.Marshal(r.Answers)
|
||||
gpaiResult, _ := json.Marshal(r.GPAIResult)
|
||||
obligations, _ := json.Marshal(r.CombinedObligations)
|
||||
articles, _ := json.Marshal(r.ApplicableArticles)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ai_act_decision_tree_results (
|
||||
id, tenant_id, project_id, system_name, system_description,
|
||||
answers, high_risk_level, gpai_result,
|
||||
combined_obligations, applicable_articles,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10,
|
||||
$11, $12
|
||||
)
|
||||
`,
|
||||
r.ID, r.TenantID, r.ProjectID, r.SystemName, r.SystemDescription,
|
||||
answers, string(r.HighRiskResult), gpaiResult,
|
||||
obligations, articles,
|
||||
r.CreatedAt, r.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDecisionTreeResult retrieves a decision tree result by ID
|
||||
func (s *Store) GetDecisionTreeResult(ctx context.Context, id uuid.UUID) (*DecisionTreeResult, error) {
|
||||
var r DecisionTreeResult
|
||||
var answersBytes, gpaiBytes, oblBytes, artBytes []byte
|
||||
var highRiskLevel string
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, project_id, system_name, system_description,
|
||||
answers, high_risk_level, gpai_result,
|
||||
combined_obligations, applicable_articles,
|
||||
created_at, updated_at
|
||||
FROM ai_act_decision_tree_results WHERE id = $1
|
||||
`, id).Scan(
|
||||
&r.ID, &r.TenantID, &r.ProjectID, &r.SystemName, &r.SystemDescription,
|
||||
&answersBytes, &highRiskLevel, &gpaiBytes,
|
||||
&oblBytes, &artBytes,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(answersBytes, &r.Answers)
|
||||
json.Unmarshal(gpaiBytes, &r.GPAIResult)
|
||||
json.Unmarshal(oblBytes, &r.CombinedObligations)
|
||||
json.Unmarshal(artBytes, &r.ApplicableArticles)
|
||||
r.HighRiskResult = AIActRiskLevel(highRiskLevel)
|
||||
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ListDecisionTreeResults lists all decision tree results for a tenant
|
||||
func (s *Store) ListDecisionTreeResults(ctx context.Context, tenantID uuid.UUID) ([]DecisionTreeResult, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, project_id, system_name, system_description,
|
||||
answers, high_risk_level, gpai_result,
|
||||
combined_obligations, applicable_articles,
|
||||
created_at, updated_at
|
||||
FROM ai_act_decision_tree_results
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []DecisionTreeResult
|
||||
for rows.Next() {
|
||||
var r DecisionTreeResult
|
||||
var answersBytes, gpaiBytes, oblBytes, artBytes []byte
|
||||
var highRiskLevel string
|
||||
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.TenantID, &r.ProjectID, &r.SystemName, &r.SystemDescription,
|
||||
&answersBytes, &highRiskLevel, &gpaiBytes,
|
||||
&oblBytes, &artBytes,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(answersBytes, &r.Answers)
|
||||
json.Unmarshal(gpaiBytes, &r.GPAIResult)
|
||||
json.Unmarshal(oblBytes, &r.CombinedObligations)
|
||||
json.Unmarshal(artBytes, &r.ApplicableArticles)
|
||||
r.HighRiskResult = AIActRiskLevel(highRiskLevel)
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DeleteDecisionTreeResult deletes a decision tree result by ID
|
||||
func (s *Store) DeleteDecisionTreeResult(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, "DELETE FROM ai_act_decision_tree_results WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
65
ai-compliance-sdk/migrations/023_ai_registration_schema.sql
Normal file
65
ai-compliance-sdk/migrations/023_ai_registration_schema.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- Migration 023: AI System Registration Schema (Art. 49 AI Act)
|
||||
-- Tracks EU AI Database registrations for High-Risk AI systems
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_system_registrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- System identification
|
||||
system_name VARCHAR(500) NOT NULL,
|
||||
system_version VARCHAR(100),
|
||||
system_description TEXT,
|
||||
intended_purpose TEXT,
|
||||
|
||||
-- Provider info
|
||||
provider_name VARCHAR(500),
|
||||
provider_legal_form VARCHAR(200),
|
||||
provider_address TEXT,
|
||||
provider_country VARCHAR(10),
|
||||
eu_representative_name VARCHAR(500),
|
||||
eu_representative_contact TEXT,
|
||||
|
||||
-- Classification
|
||||
risk_classification VARCHAR(50) DEFAULT 'not_classified',
|
||||
-- CHECK (risk_classification IN ('not_classified', 'minimal_risk', 'limited_risk', 'high_risk', 'unacceptable'))
|
||||
annex_iii_category VARCHAR(200),
|
||||
gpai_classification VARCHAR(50) DEFAULT 'none',
|
||||
-- CHECK (gpai_classification IN ('none', 'standard', 'systemic'))
|
||||
|
||||
-- Conformity
|
||||
conformity_assessment_type VARCHAR(50),
|
||||
-- CHECK (conformity_assessment_type IN ('internal', 'third_party', 'not_required'))
|
||||
notified_body_name VARCHAR(500),
|
||||
notified_body_id VARCHAR(100),
|
||||
ce_marking BOOLEAN DEFAULT false,
|
||||
|
||||
-- Training data
|
||||
training_data_categories JSONB DEFAULT '[]'::jsonb,
|
||||
training_data_summary TEXT,
|
||||
|
||||
-- Registration status
|
||||
registration_status VARCHAR(50) DEFAULT 'draft',
|
||||
-- CHECK (registration_status IN ('draft', 'ready', 'submitted', 'registered', 'update_required', 'withdrawn'))
|
||||
eu_database_id VARCHAR(200),
|
||||
registration_date TIMESTAMPTZ,
|
||||
last_update_date TIMESTAMPTZ,
|
||||
|
||||
-- Links to other assessments
|
||||
ucca_assessment_id UUID,
|
||||
decision_tree_result_id UUID,
|
||||
|
||||
-- Export data (cached JSON for EU submission)
|
||||
export_data JSONB,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by VARCHAR(200),
|
||||
submitted_by VARCHAR(200)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_air_tenant ON ai_system_registrations (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_air_status ON ai_system_registrations (registration_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_air_classification ON ai_system_registrations (risk_classification);
|
||||
CREATE INDEX IF NOT EXISTS idx_air_ucca ON ai_system_registrations (ucca_assessment_id);
|
||||
@@ -11,7 +11,7 @@
|
||||
"id": "ai_act",
|
||||
"file": "ai_act_v2.json",
|
||||
"version": "1.0",
|
||||
"count": 60
|
||||
"count": 81
|
||||
},
|
||||
{
|
||||
"id": "nis2",
|
||||
@@ -54,8 +54,20 @@
|
||||
"file": "dora_v2.json",
|
||||
"version": "1.0",
|
||||
"count": 20
|
||||
},
|
||||
{
|
||||
"id": "betrvg",
|
||||
"file": "betrvg_v2.json",
|
||||
"version": "1.0",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"id": "agg",
|
||||
"file": "agg_v2.json",
|
||||
"version": "1.0",
|
||||
"count": 8
|
||||
}
|
||||
],
|
||||
"tom_mapping_file": "_tom_mapping.json",
|
||||
"total_obligations": 325
|
||||
"total_obligations": 366
|
||||
}
|
||||
140
ai-compliance-sdk/policies/obligations/v2/agg_v2.json
Normal file
140
ai-compliance-sdk/policies/obligations/v2/agg_v2.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"regulation": "agg",
|
||||
"regulation_full_name": "Allgemeines Gleichbehandlungsgesetz (AGG)",
|
||||
"version": "1.0",
|
||||
"obligations": [
|
||||
{
|
||||
"id": "AGG-OBL-001",
|
||||
"title": "Diskriminierungsfreie Gestaltung von KI-Auswahlverfahren",
|
||||
"description": "KI-gestuetzte Auswahlverfahren (Recruiting, Befoerderung, Kuendigung) muessen so gestaltet sein, dass keine Benachteiligung nach § 1 AGG Merkmalen (Geschlecht, Alter, ethnische Herkunft, Religion, Behinderung, sexuelle Identitaet) erfolgt.",
|
||||
"applies_when": "AI system used in employment decisions",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.automated_screening", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 1, § 7", "title": "Benachteiligungsverbot" }, { "norm": "AGG", "article": "§ 11", "title": "Ausschreibung" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§ 1, § 7, § 11 AGG" }],
|
||||
"category": "Governance",
|
||||
"responsible": "HR / Compliance",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einsatz im Auswahlverfahren" },
|
||||
"sanctions": { "description": "Schadensersatz bis 3 Monatsgehaelter (§ 15 AGG), Beweislastumkehr (§ 22 AGG)" },
|
||||
"evidence": [{ "name": "Bias-Audit-Bericht", "required": true }, "AGG-Konformitaetspruefung"],
|
||||
"priority": "kritisch",
|
||||
"tom_control_ids": ["TOM.FAIR.01"],
|
||||
"breakpilot_feature": "/sdk/use-cases",
|
||||
"valid_from": "2006-08-18",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-002",
|
||||
"title": "Keine Nutzung von Proxy-Merkmalen fuer Diskriminierung",
|
||||
"description": "Das KI-System darf keine Proxy-Merkmale verwenden, die indirekt auf geschuetzte Kategorien schliessen lassen (z.B. Name → Herkunft, Foto → Alter/Geschlecht, PLZ → sozialer Hintergrund).",
|
||||
"applies_when": "AI processes applicant data with identifiable features",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.agg_categories_visible", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 3 Abs. 2", "title": "Mittelbare Benachteiligung" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§ 3 Abs. 2 AGG" }],
|
||||
"category": "Technisch",
|
||||
"responsible": "Data Science / Compliance",
|
||||
"priority": "kritisch",
|
||||
"evidence": [{ "name": "Feature-Analyse-Dokumentation (keine Proxy-Merkmale)", "required": true }],
|
||||
"tom_control_ids": ["TOM.FAIR.01"],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-003",
|
||||
"title": "Beweislast-Dokumentation fuehren (§ 22 AGG)",
|
||||
"description": "Bei Indizien fuer eine Benachteiligung kehrt sich die Beweislast um (§ 22 AGG). Der Arbeitgeber muss beweisen, dass KEINE Diskriminierung vorliegt. Daher ist lueckenlose Dokumentation der KI-Entscheidungslogik zwingend.",
|
||||
"applies_when": "AI supports employment decisions in Germany",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 22", "title": "Beweislast" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§ 22 AGG" }],
|
||||
"category": "Governance",
|
||||
"responsible": "HR / Legal",
|
||||
"priority": "kritisch",
|
||||
"deadline": { "type": "recurring", "interval": "laufend" },
|
||||
"sanctions": { "description": "Ohne Dokumentation kann Beweislastumkehr nicht abgewehrt werden — Schadensersatz nach § 15 AGG" },
|
||||
"evidence": [{ "name": "Entscheidungsprotokoll mit KI-Begruendung", "required": true }, "Audit-Trail aller KI-Bewertungen"],
|
||||
"tom_control_ids": ["TOM.LOG.01", "TOM.GOV.01"],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-004",
|
||||
"title": "Regelmaessige Bias-Audits bei KI-gestuetzter Personalauswahl",
|
||||
"description": "KI-Systeme im Recruiting muessen regelmaessig auf Bias geprueft werden: statistische Analyse der Ergebnisse nach Geschlecht, Altersgruppen und soweit zulaessig nach Herkunft.",
|
||||
"applies_when": "AI ranks or scores candidates",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.candidate_ranking", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 1, § 3", "title": "Unmittelbare und mittelbare Benachteiligung" }],
|
||||
"category": "Technisch",
|
||||
"responsible": "Data Science",
|
||||
"priority": "hoch",
|
||||
"deadline": { "type": "recurring", "interval": "quartalsweise" },
|
||||
"evidence": [{ "name": "Bias-Audit-Ergebnis (letzte 3 Monate)", "required": true }],
|
||||
"tom_control_ids": ["TOM.FAIR.01"],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-005",
|
||||
"title": "Schulung der HR-Entscheider ueber KI-Grenzen",
|
||||
"description": "Personen, die KI-gestuetzte Empfehlungen im Personalbereich nutzen, muessen ueber Systemgrenzen, Bias-Risiken und ihre Pflicht zur eigenstaendigen Pruefung geschult werden.",
|
||||
"applies_when": "AI provides recommendations for HR decisions",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 12 Abs. 2", "title": "Pflicht des Arbeitgebers zu Schutzmassnahmen" }],
|
||||
"category": "Organisatorisch",
|
||||
"responsible": "HR / Training",
|
||||
"priority": "hoch",
|
||||
"deadline": { "type": "recurring", "interval": "jaehrlich" },
|
||||
"evidence": [{ "name": "Schulungsnachweis AGG + KI-Kompetenz", "required": true }],
|
||||
"tom_control_ids": [],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-006",
|
||||
"title": "Beschwerdemechanismus fuer abgelehnte Bewerber",
|
||||
"description": "Bewerber muessen die Moeglichkeit haben, sich ueber KI-gestuetzte Auswahlentscheidungen zu beschweren. Die zustaendige Stelle (§ 13 AGG) muss benannt sein.",
|
||||
"applies_when": "AI used in applicant selection process",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.automated_screening", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 13", "title": "Beschwerderecht" }],
|
||||
"category": "Organisatorisch",
|
||||
"responsible": "HR",
|
||||
"priority": "hoch",
|
||||
"evidence": [{ "name": "Dokumentierter Beschwerdemechanismus", "required": true }],
|
||||
"tom_control_ids": [],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-007",
|
||||
"title": "Schadensersatzrisiko dokumentieren und versichern",
|
||||
"description": "Das Schadensersatzrisiko bei AGG-Verstoessen (bis 3 Monatsgehaelter pro Fall, § 15 AGG) muss bewertet und dokumentiert werden. Bei hohem Bewerbungsvolumen kann das kumulierte Risiko erheblich sein.",
|
||||
"applies_when": "AI processes high volume of applications",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.automated_screening", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 15", "title": "Entschaedigung und Schadensersatz" }],
|
||||
"category": "Governance",
|
||||
"responsible": "Legal / Finance",
|
||||
"priority": "hoch",
|
||||
"evidence": [{ "name": "Risikobewertung AGG-Schadensersatz", "required": false }],
|
||||
"tom_control_ids": [],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "AGG-OBL-008",
|
||||
"title": "KI-Stellenausschreibungen AGG-konform gestalten",
|
||||
"description": "Wenn KI bei der Erstellung oder Optimierung von Stellenausschreibungen eingesetzt wird, muss sichergestellt sein, dass die Ausschreibungen keine diskriminierenden Formulierungen enthalten (§ 11 AGG).",
|
||||
"applies_when": "AI generates or optimizes job postings",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }] },
|
||||
"legal_basis": [{ "norm": "AGG", "article": "§ 11", "title": "Ausschreibung" }],
|
||||
"category": "Organisatorisch",
|
||||
"responsible": "HR / Marketing",
|
||||
"priority": "hoch",
|
||||
"evidence": [{ "name": "Pruefprotokoll Stellenausschreibung auf AGG-Konformitaet", "required": false }],
|
||||
"tom_control_ids": [],
|
||||
"valid_from": "2006-08-18",
|
||||
"version": "1.0"
|
||||
}
|
||||
],
|
||||
"controls": [],
|
||||
"incident_deadlines": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
250
ai-compliance-sdk/policies/obligations/v2/betrvg_v2.json
Normal file
250
ai-compliance-sdk/policies/obligations/v2/betrvg_v2.json
Normal file
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"regulation": "betrvg",
|
||||
"regulation_full_name": "Betriebsverfassungsgesetz (BetrVG)",
|
||||
"version": "1.0",
|
||||
"obligations": [
|
||||
{
|
||||
"id": "BETRVG-OBL-001",
|
||||
"title": "Mitbestimmung bei technischen Ueberwachungseinrichtungen",
|
||||
"description": "Einfuehrung und Anwendung von technischen Einrichtungen, die dazu bestimmt sind, das Verhalten oder die Leistung der Arbeitnehmer zu ueberwachen, beduerfen der Zustimmung des Betriebsrats. Das BAG hat klargestellt, dass bereits die objektive Eignung zur Ueberwachung genuegt — eine tatsaechliche Nutzung zu diesem Zweck ist nicht erforderlich (BAG 1 ABR 20/21, 1 ABN 36/18).",
|
||||
"applies_when": "technical system can monitor employee behavior or performance",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "IN_ARRAY", "value": ["DE", "AT"] }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei technischen Ueberwachungseinrichtungen" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§ 87 Abs. 1 Nr. 6 BetrVG" }, { "type": "court_decision", "ref": "BAG 1 ABR 20/21 (Microsoft 365)" }, { "type": "court_decision", "ref": "BAG 1 ABN 36/18 (Standardsoftware)" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "Arbeitgeber / HR",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einfuehrung des Systems" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch des Betriebsrats, einstweilige Verfuegung moeglich, Betriebsvereinbarung ueber Einigungsstelle erzwingbar (§ 87 Abs. 2 BetrVG)" },
|
||||
"evidence": [{ "name": "Betriebsvereinbarung oder dokumentierte Zustimmung des Betriebsrats", "required": true }, "Protokoll der Betriebsratssitzung"],
|
||||
"priority": "kritisch",
|
||||
"tom_control_ids": ["TOM.GOV.01", "TOM.AC.01"],
|
||||
"breakpilot_feature": "/sdk/betriebsvereinbarung",
|
||||
"valid_from": "1972-01-19",
|
||||
"valid_until": null,
|
||||
"version": "1.0",
|
||||
"how_to_implement": "Betriebsrat fruehzeitig informieren, gemeinsame Bewertung der Ueberwachungseignung durchfuehren, Betriebsvereinbarung mit Zweckbindung und verbotenen Nutzungen abschliessen."
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-002",
|
||||
"title": "Keine Geringfuegigkeitsschwelle bei Standardsoftware",
|
||||
"description": "Auch alltaegliche Standardsoftware (Excel, Word, E-Mail-Clients) unterliegt der Mitbestimmung, wenn sie objektiv geeignet ist, Verhaltens- oder Leistungsdaten zu erheben. Es gibt keine Geringfuegigkeitsschwelle (BAG 1 ABN 36/18).",
|
||||
"applies_when": "any software used by employees that can log or track usage",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung — keine Geringfuegigkeitsschwelle" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABN 36/18" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "IT-Leitung / HR",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einfuehrung oder Aenderung" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch, einstweilige Verfuegung" },
|
||||
"evidence": [{ "name": "Bestandsaufnahme aller IT-Systeme mit Ueberwachungseignung", "required": true }],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": [],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2018-10-23",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-003",
|
||||
"title": "Mitbestimmung bei Ueberwachung durch Drittsysteme (SaaS/Cloud)",
|
||||
"description": "Auch wenn die Ueberwachung ueber ein Dritt-System (SaaS, Cloud, externer Anbieter) laeuft, bleibt der Betriebsrat zu beteiligen. Die Verantwortung des Arbeitgebers entfaellt nicht durch Auslagerung (BAG 1 ABR 68/13).",
|
||||
"applies_when": "cloud or SaaS system processes employee data",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Drittsystemen" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 68/13" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "IT-Leitung / Einkauf",
|
||||
"deadline": { "type": "on_event", "event": "Vor Vertragsschluss mit SaaS-Anbieter" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch" },
|
||||
"evidence": [{ "name": "Datenschutz-Folgenabschaetzung fuer Cloud-Dienst", "required": false }, "Betriebsvereinbarung"],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": ["TOM.PROC.01"],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2015-07-21",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-004",
|
||||
"title": "Mitbestimmung bei E-Mail- und Kommunikationssoftware",
|
||||
"description": "Sowohl Einfuehrung als auch Nutzung softwarebasierter Anwendungen fuer die E-Mail-Kommunikation sind mitbestimmungspflichtig (BAG 1 ABR 31/19). Dies gilt auch fuer Teams, Slack und vergleichbare Messenger.",
|
||||
"applies_when": "organization introduces or changes email or messaging systems",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Kommunikationssoftware" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 31/19" }, { "type": "court_decision", "ref": "BAG 1 ABR 46/10" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "IT-Leitung / HR",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einfuehrung oder Funktionsaenderung" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch, einstweilige Verfuegung" },
|
||||
"evidence": [{ "name": "Betriebsvereinbarung zu E-Mail-/Messaging-Nutzung", "required": true }],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": ["TOM.AC.01"],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2021-01-27",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-005",
|
||||
"title": "Verbot der dauerhaften Leistungsueberwachung",
|
||||
"description": "Eine dauerhafte quantitative Erfassung und Auswertung einzelner Arbeitsschritte stellt einen schwerwiegenden Eingriff in das Persoenlichkeitsrecht dar (BAG 1 ABR 46/15). Belastungsstatistiken und KPI-Dashboards auf Personenebene beduerfen besonderer Rechtfertigung.",
|
||||
"applies_when": "system provides individual performance metrics or KPIs",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "purpose.profiling", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Persoenlichkeitsschutz bei Kennzahlenueberwachung" }, { "norm": "GG", "article": "Art. 2 Abs. 1 i.V.m. Art. 1 Abs. 1", "title": "Allgemeines Persoenlichkeitsrecht" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 46/15 (Belastungsstatistik)" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "HR / Compliance",
|
||||
"deadline": { "type": "recurring", "interval": "laufend" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch, Schadensersatz bei Persoenlichkeitsrechtsverletzung" },
|
||||
"evidence": [{ "name": "Nachweis dass keine individuelle Leistungsueberwachung stattfindet", "required": true }],
|
||||
"priority": "kritisch",
|
||||
"tom_control_ids": ["TOM.GOV.03"],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2017-04-25",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-006",
|
||||
"title": "Unterrichtung bei Planung technischer Anlagen",
|
||||
"description": "Der Arbeitgeber hat den Betriebsrat ueber die Planung von technischen Anlagen rechtzeitig unter Vorlage der erforderlichen Unterlagen zu unterrichten und mit ihm zu beraten.",
|
||||
"applies_when": "organization plans new technical infrastructure",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 90 Abs. 1 Nr. 3", "title": "Unterrichtungs- und Beratungsrechte bei Planung" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§ 90 BetrVG" }],
|
||||
"category": "Information",
|
||||
"responsible": "IT-Leitung",
|
||||
"deadline": { "type": "on_event", "event": "Rechtzeitig vor Umsetzung" },
|
||||
"sanctions": { "description": "Beratungsanspruch, ggf. Aussetzung der Massnahme" },
|
||||
"evidence": [{ "name": "Unterrichtungsschreiben an Betriebsrat mit technischer Dokumentation", "required": true }],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": [],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "1972-01-19",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-007",
|
||||
"title": "Mitbestimmung bei Personalfrageboegen und Bewertungssystemen",
|
||||
"description": "Personalfrageboegen und allgemeine Beurteilungsgrundsaetze beduerfen der Zustimmung des Betriebsrats. Dies umfasst auch KI-gestuetzte Bewertungssysteme fuer Mitarbeiterbeurteilungen (BAG 1 ABR 40/07).",
|
||||
"applies_when": "AI or IT system supports employee evaluation or surveys",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "purpose.profiling", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 94", "title": "Personalfrageboegen, Beurteilungsgrundsaetze" }, { "norm": "BetrVG", "article": "§ 95", "title": "Auswahlrichtlinien" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 40/07" }, { "type": "court_decision", "ref": "BAG 1 ABR 16/07" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "HR",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einfuehrung des Bewertungssystems" },
|
||||
"sanctions": { "description": "Nichtigkeit der Bewertung, Unterlassungsanspruch" },
|
||||
"evidence": [{ "name": "Betriebsvereinbarung zu Beurteilungsgrundsaetzen", "required": true }],
|
||||
"priority": "kritisch",
|
||||
"tom_control_ids": ["TOM.GOV.01"],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "1972-01-19",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-008",
|
||||
"title": "Mitbestimmung bei KI-gestuetztem Recruiting",
|
||||
"description": "KI-Systeme im Recruiting-Prozess (CV-Screening, Ranking, Vorselektion) beruehren die Mitbestimmung bei Auswahlrichtlinien (§ 95 BetrVG) und ggf. bei Einstellungen (§ 99 BetrVG). Zusaetzlich AI Act Hochrisiko-Klassifikation (Annex III Nr. 4).",
|
||||
"applies_when": "AI system used in hiring, promotion or termination decisions",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "purpose.automation", "operator": "EQUALS", "value": true }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 95", "title": "Auswahlrichtlinien" }, { "norm": "BetrVG", "article": "§ 99", "title": "Mitbestimmung bei personellen Einzelmassnahmen" }, { "norm": "EU AI Act", "article": "Annex III Nr. 4", "title": "Hochrisiko: Beschaeftigung" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§ 95, § 99 BetrVG" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "HR / Legal",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einsatz im Recruiting" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch, Anfechtung der Einstellung, AI Act Bussgeld bei Hochrisiko-Verstoss" },
|
||||
"evidence": [{ "name": "Betriebsvereinbarung KI im Recruiting", "required": true }, "DSFA", "AI Act Konformitaetsbewertung"],
|
||||
"priority": "kritisch",
|
||||
"tom_control_ids": ["TOM.GOV.01", "TOM.FAIR.01"],
|
||||
"breakpilot_feature": "/sdk/ai-act",
|
||||
"valid_from": "1972-01-19",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-009",
|
||||
"title": "Mitbestimmung bei Betriebsaenderungen durch KI",
|
||||
"description": "Grundlegende Aenderung der Betriebsorganisation durch KI-Einfuehrung kann eine Betriebsaenderung darstellen. In Unternehmen mit mehr als 20 wahlberechtigten Arbeitnehmern ist ein Interessenausgleich zu versuchen und ein Sozialplan aufzustellen.",
|
||||
"applies_when": "AI introduction fundamentally changes work organization",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "organization.employee_count", "operator": "GREATER_THAN", "value": 20 }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 111", "title": "Betriebsaenderungen" }, { "norm": "BetrVG", "article": "§ 112", "title": "Interessenausgleich, Sozialplan" }],
|
||||
"sources": [{ "type": "national_law", "ref": "§§ 111-113 BetrVG" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "Geschaeftsfuehrung / HR",
|
||||
"deadline": { "type": "on_event", "event": "Rechtzeitig vor Umsetzung" },
|
||||
"sanctions": { "description": "Nachteilsausgleich, Sozialplananspruch, Anfechtung der Massnahme" },
|
||||
"evidence": [{ "name": "Interessenausgleich", "required": false }, "Sozialplan", "Unterrichtung des Betriebsrats"],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": [],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "1972-01-19",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-010",
|
||||
"title": "Zustaendigkeit bei konzernweiten IT-Systemen",
|
||||
"description": "Bei konzernweit eingesetzten IT-Systemen (z.B. M365, SAP) kann nicht der lokale Betriebsrat, sondern der Gesamt- oder Konzernbetriebsrat zustaendig sein (BAG 1 ABR 45/11). Die Zustaendigkeitsabgrenzung ist vor Einfuehrung zu klaeren.",
|
||||
"applies_when": "IT system deployed across multiple establishments or companies",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 50 Abs. 1", "title": "Zustaendigkeit Gesamtbetriebsrat" }, { "norm": "BetrVG", "article": "§ 58 Abs. 1", "title": "Zustaendigkeit Konzernbetriebsrat" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 45/11 (SAP ERP)" }, { "type": "court_decision", "ref": "BAG 1 ABR 2/05" }],
|
||||
"category": "Organisation",
|
||||
"responsible": "HR / Legal",
|
||||
"deadline": { "type": "on_event", "event": "Vor Einfuehrung" },
|
||||
"sanctions": { "description": "Unwirksamkeit der Vereinbarung bei falschem Verhandlungspartner" },
|
||||
"evidence": [{ "name": "Zustaendigkeitsbestimmung dokumentiert", "required": true }],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": [],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2012-09-25",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-011",
|
||||
"title": "Change-Management — erneute Mitbestimmung bei Funktionserweiterungen",
|
||||
"description": "Neue Module, Funktionen oder Konnektoren in bestehenden IT-Systemen koennen eine erneute Mitbestimmung ausloesen, wenn sie die Ueberwachungseignung aendern oder erweitern (BAG 1 ABR 20/21 — Anwendung, nicht nur Einfuehrung).",
|
||||
"applies_when": "existing IT system receives feature updates affecting monitoring capability",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Anwendung (nicht nur Einfuehrung)" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 20/21" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "IT-Leitung / HR",
|
||||
"deadline": { "type": "on_event", "event": "Vor Aktivierung neuer Funktionen" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch" },
|
||||
"evidence": [{ "name": "Change-Management-Protokoll mit BR-Bewertung", "required": true }],
|
||||
"priority": "hoch",
|
||||
"tom_control_ids": [],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2022-03-08",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"id": "BETRVG-OBL-012",
|
||||
"title": "Videoueberwachung — Mitbestimmung und Verhaeltnismaessigkeit",
|
||||
"description": "Videoueberwachung am Arbeitsplatz ist grundsaetzlich mitbestimmungspflichtig. Die Regelungen ueber Einfuehrung und Ausgestaltung beduerfen der Zustimmung des Betriebsrats (BAG 1 ABR 78/11, 1 ABR 21/03).",
|
||||
"applies_when": "organization uses video surveillance that may capture employees",
|
||||
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_protection.video_surveillance", "operator": "EQUALS", "value": true }] },
|
||||
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Videoueberwachung" }],
|
||||
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 78/11" }, { "type": "court_decision", "ref": "BAG 1 ABR 21/03" }],
|
||||
"category": "Mitbestimmung",
|
||||
"responsible": "Facility Management / HR",
|
||||
"deadline": { "type": "on_event", "event": "Vor Installation" },
|
||||
"sanctions": { "description": "Unterlassungsanspruch, Beweisverwertungsverbot" },
|
||||
"evidence": [{ "name": "Betriebsvereinbarung Videoueberwachung", "required": true }, "Beschilderung"],
|
||||
"priority": "kritisch",
|
||||
"tom_control_ids": ["TOM.PHY.01"],
|
||||
"breakpilot_feature": null,
|
||||
"valid_from": "2004-06-29",
|
||||
"valid_until": null,
|
||||
"version": "1.0"
|
||||
}
|
||||
],
|
||||
"controls": [],
|
||||
"incident_deadlines": []
|
||||
}
|
||||
@@ -941,6 +941,618 @@ rules:
|
||||
gdpr_ref: "Art. 9(2)(h) DSGVO"
|
||||
rationale: "Gesundheitsdaten nur mit besonderen Schutzmaßnahmen"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# K. Domain-spezifische Hochrisiko-Fragen (Annex III)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# HR / Recruiting (Annex III Nr. 4)
|
||||
- id: R-HR-001
|
||||
category: "K. HR Hochrisiko"
|
||||
title: "Automatisches Bewerber-Screening ohne Human Review"
|
||||
description: "KI sortiert Bewerber vor ohne dass ein Mensch jede Empfehlung tatsaechlich prueft"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "hr_context.automated_screening"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "hr_context.human_review_enforced"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect:
|
||||
risk_add: 20
|
||||
feasibility: CONDITIONAL
|
||||
controls_add: [C_HUMAN_OVERSIGHT]
|
||||
severity: WARN
|
||||
gdpr_ref: "Art. 22 DSGVO + Annex III Nr. 4 AI Act"
|
||||
rationale: "Ohne echtes Human Review droht Art. 22 DSGVO Verstoss"
|
||||
|
||||
- id: R-HR-002
|
||||
category: "K. HR Hochrisiko"
|
||||
title: "Automatisierte Absagen — Art. 22 DSGVO Risiko"
|
||||
description: "KI generiert und versendet Absagen automatisch ohne menschliche Freigabe"
|
||||
condition:
|
||||
field: "hr_context.automated_rejection"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 25
|
||||
feasibility: NO
|
||||
art22_risk: true
|
||||
severity: BLOCK
|
||||
gdpr_ref: "Art. 22 Abs. 1 DSGVO"
|
||||
rationale: "Vollautomatische Ablehnung = ausschliesslich automatisierte Entscheidung mit rechtlicher Wirkung"
|
||||
|
||||
- id: R-HR-003
|
||||
category: "K. HR Hochrisiko"
|
||||
title: "AGG-relevante Merkmale fuer KI erkennbar"
|
||||
description: "System kann Merkmale nach § 1 AGG erkennen (Name, Foto, Alter → Proxy-Diskriminierung)"
|
||||
condition:
|
||||
field: "hr_context.agg_categories_visible"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 15
|
||||
controls_add: [C_BIAS_AUDIT]
|
||||
severity: WARN
|
||||
gdpr_ref: "§ 1, § 3 Abs. 2 AGG"
|
||||
rationale: "Proxy-Merkmale koennen indirekte Diskriminierung verursachen"
|
||||
|
||||
- id: R-HR-004
|
||||
category: "K. HR Hochrisiko"
|
||||
title: "Bewerber-Ranking ohne Bias-Audit"
|
||||
description: "KI erstellt Bewerber-Rankings ohne regelmaessige Bias-Pruefung"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "hr_context.candidate_ranking"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "hr_context.bias_audits_done"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect:
|
||||
risk_add: 15
|
||||
controls_add: [C_BIAS_AUDIT]
|
||||
severity: WARN
|
||||
gdpr_ref: "§ 22 AGG (Beweislastumkehr)"
|
||||
rationale: "Ohne Bias-Audit keine Verteidigung bei AGG-Klage"
|
||||
|
||||
- id: R-HR-005
|
||||
category: "K. HR Hochrisiko"
|
||||
title: "KI-gestuetzte Mitarbeiterbewertung"
|
||||
description: "KI bewertet Mitarbeiterleistung (Performance Review, KPI-Tracking)"
|
||||
condition:
|
||||
field: "hr_context.performance_evaluation"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 20
|
||||
severity: WARN
|
||||
gdpr_ref: "§ 87 Abs. 1 Nr. 6 BetrVG + § 94 BetrVG"
|
||||
rationale: "Leistungsbewertung durch KI ist mitbestimmungspflichtig und diskriminierungsriskant"
|
||||
|
||||
# Education (Annex III Nr. 3)
|
||||
- id: R-EDU-001
|
||||
category: "K. Bildung Hochrisiko"
|
||||
title: "KI beeinflusst Notenvergabe"
|
||||
description: "KI erstellt Notenvorschlaege oder beeinflusst Bewertungen"
|
||||
condition:
|
||||
field: "education_context.grade_influence"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 20
|
||||
controls_add: [C_HUMAN_OVERSIGHT]
|
||||
dsfa_recommended: true
|
||||
severity: WARN
|
||||
gdpr_ref: "Annex III Nr. 3 AI Act"
|
||||
rationale: "Notenvergabe hat erhebliche Auswirkungen auf Bildungschancen"
|
||||
|
||||
- id: R-EDU-002
|
||||
category: "K. Bildung Hochrisiko"
|
||||
title: "Minderjaehrige betroffen ohne Lehrkraft-Review"
|
||||
description: "KI-System betrifft Minderjaehrige und Lehrkraft prueft nicht jedes Ergebnis"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "education_context.minors_involved"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "education_context.teacher_review_required"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect:
|
||||
risk_add: 25
|
||||
feasibility: NO
|
||||
severity: BLOCK
|
||||
gdpr_ref: "Art. 24 EU-Grundrechtecharta + Annex III Nr. 3 AI Act"
|
||||
rationale: "KI-Entscheidungen ueber Minderjaehrige ohne Lehrkraft-Kontrolle sind unzulaessig"
|
||||
|
||||
- id: R-EDU-003
|
||||
category: "K. Bildung Hochrisiko"
|
||||
title: "KI steuert Zugang zu Bildungsangeboten"
|
||||
description: "KI beeinflusst Zulassung, Kursempfehlungen oder Einstufungen"
|
||||
condition:
|
||||
field: "education_context.student_selection"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 20
|
||||
dsfa_recommended: true
|
||||
severity: WARN
|
||||
gdpr_ref: "Art. 14 EU-Grundrechtecharta (Recht auf Bildung)"
|
||||
rationale: "Zugangssteuerung zu Bildung ist hochrisiko nach AI Act"
|
||||
|
||||
# Healthcare (Annex III Nr. 5)
|
||||
- id: R-HC-001
|
||||
category: "K. Gesundheit Hochrisiko"
|
||||
title: "KI unterstuetzt Diagnosen"
|
||||
description: "KI erstellt Diagnosevorschlaege oder wertet Bildgebung aus"
|
||||
condition:
|
||||
field: "healthcare_context.diagnosis_support"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 20
|
||||
dsfa_recommended: true
|
||||
controls_add: [C_HUMAN_OVERSIGHT]
|
||||
severity: WARN
|
||||
gdpr_ref: "Annex III Nr. 5 AI Act + MDR (EU) 2017/745"
|
||||
rationale: "Diagnoseunterstuetzung erfordert hoechste Genauigkeit und Human Oversight"
|
||||
|
||||
- id: R-HC-002
|
||||
category: "K. Gesundheit Hochrisiko"
|
||||
title: "Triage-Entscheidung durch KI"
|
||||
description: "KI priorisiert Patienten nach Dringlichkeit"
|
||||
condition:
|
||||
field: "healthcare_context.triage_decision"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 30
|
||||
feasibility: CONDITIONAL
|
||||
controls_add: [C_HUMAN_OVERSIGHT]
|
||||
dsfa_recommended: true
|
||||
severity: WARN
|
||||
gdpr_ref: "Annex III Nr. 5 AI Act"
|
||||
rationale: "Lebenskritische Priorisierung erfordert maximale Sicherheit"
|
||||
|
||||
- id: R-HC-003
|
||||
category: "K. Gesundheit Hochrisiko"
|
||||
title: "Medizinprodukt ohne klinische Validierung"
|
||||
description: "System ist als Medizinprodukt eingestuft aber nicht klinisch validiert"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "healthcare_context.medical_device"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "healthcare_context.clinical_validation"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect:
|
||||
risk_add: 30
|
||||
feasibility: NO
|
||||
severity: BLOCK
|
||||
gdpr_ref: "MDR (EU) 2017/745 Art. 61"
|
||||
rationale: "Medizinprodukte ohne klinische Validierung duerfen nicht in Verkehr gebracht werden"
|
||||
|
||||
- id: R-HC-004
|
||||
category: "K. Gesundheit Hochrisiko"
|
||||
title: "Gesundheitsdaten ohne besondere Schutzmassnahmen"
|
||||
description: "Gesundheitsdaten (Art. 9 DSGVO) werden verarbeitet"
|
||||
condition:
|
||||
field: "healthcare_context.patient_data_processed"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect:
|
||||
risk_add: 15
|
||||
dsfa_recommended: true
|
||||
controls_add: [C_DSFA]
|
||||
severity: WARN
|
||||
gdpr_ref: "Art. 9 DSGVO"
|
||||
rationale: "Gesundheitsdaten sind besondere Kategorien mit erhoehtem Schutzbedarf"
|
||||
|
||||
# Legal / Justice (Annex III Nr. 8)
|
||||
- id: R-LEG-001
|
||||
category: "K. Legal Hochrisiko"
|
||||
title: "KI gibt Rechtsberatung"
|
||||
description: "KI generiert rechtliche Empfehlungen oder Einschaetzungen"
|
||||
condition: { field: "legal_context.legal_advice", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, controls_add: [C_HUMAN_OVERSIGHT] }
|
||||
severity: WARN
|
||||
gdpr_ref: "Annex III Nr. 8 AI Act"
|
||||
rationale: "Rechtsberatung durch KI kann Zugang zur Justiz beeintraechtigen"
|
||||
|
||||
- id: R-LEG-002
|
||||
category: "K. Legal Hochrisiko"
|
||||
title: "KI prognostiziert Gerichtsurteile"
|
||||
description: "System erstellt Prognosen ueber Verfahrensausgaenge"
|
||||
condition: { field: "legal_context.court_prediction", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Urteilsprognosen koennen rechtliches Verhalten verzerren"
|
||||
|
||||
- id: R-LEG-003
|
||||
category: "K. Legal Hochrisiko"
|
||||
title: "Mandantengeheimnis bei KI-Verarbeitung"
|
||||
description: "Vertrauliche Mandantendaten werden durch KI verarbeitet"
|
||||
condition: { field: "legal_context.client_confidential", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, controls_add: [C_ENCRYPTION] }
|
||||
severity: WARN
|
||||
rationale: "Mandantengeheimnis erfordert besonderen Schutz (§ 203 StGB)"
|
||||
|
||||
# Public Sector (Art. 27 FRIA)
|
||||
- id: R-PUB-001
|
||||
category: "K. Oeffentlicher Sektor"
|
||||
title: "KI in Verwaltungsentscheidungen"
|
||||
description: "KI beeinflusst Verwaltungsakte oder Bescheide"
|
||||
condition: { field: "public_sector_context.admin_decision", operator: "equals", value: true }
|
||||
effect: { risk_add: 25, dsfa_recommended: true, controls_add: [C_FRIA, C_HUMAN_OVERSIGHT] }
|
||||
severity: WARN
|
||||
rationale: "Verwaltungsentscheidungen erfordern FRIA (Art. 27 AI Act)"
|
||||
|
||||
- id: R-PUB-002
|
||||
category: "K. Oeffentlicher Sektor"
|
||||
title: "KI verteilt oeffentliche Leistungen"
|
||||
description: "KI entscheidet ueber Zuteilung von Sozialleistungen oder Foerderung"
|
||||
condition: { field: "public_sector_context.benefit_allocation", operator: "equals", value: true }
|
||||
effect: { risk_add: 25, feasibility: CONDITIONAL }
|
||||
severity: WARN
|
||||
rationale: "Leistungszuteilung betrifft Grundrecht auf soziale Sicherheit"
|
||||
|
||||
- id: R-PUB-003
|
||||
category: "K. Oeffentlicher Sektor"
|
||||
title: "Fehlende Transparenz gegenueber Buergern"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "public_sector_context.citizen_service"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "public_sector_context.transparency_ensured"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect: { risk_add: 15, controls_add: [C_TRANSPARENCY] }
|
||||
severity: WARN
|
||||
rationale: "Oeffentliche Stellen haben erhoehte Transparenzpflicht"
|
||||
|
||||
# Critical Infrastructure (NIS2 + Annex III Nr. 2)
|
||||
- id: R-CRIT-001
|
||||
category: "K. Kritische Infrastruktur"
|
||||
title: "Sicherheitskritische KI-Steuerung ohne Redundanz"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "critical_infra_context.safety_critical"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "critical_infra_context.redundancy_exists"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect: { risk_add: 30, feasibility: NO }
|
||||
severity: BLOCK
|
||||
rationale: "Sicherheitskritische Steuerung ohne Redundanz ist unzulaessig"
|
||||
|
||||
- id: R-CRIT-002
|
||||
category: "K. Kritische Infrastruktur"
|
||||
title: "KI steuert Netz-/Infrastruktur"
|
||||
condition: { field: "critical_infra_context.grid_control", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, controls_add: [C_INCIDENT_RESPONSE, C_HUMAN_OVERSIGHT] }
|
||||
severity: WARN
|
||||
rationale: "Netzsteuerung durch KI erfordert NIS2-konforme Absicherung"
|
||||
|
||||
# Automotive / Aerospace
|
||||
- id: R-AUTO-001
|
||||
category: "K. Automotive Hochrisiko"
|
||||
title: "Autonomes Fahren / ADAS"
|
||||
condition: { field: "automotive_context.autonomous_driving", operator: "equals", value: true }
|
||||
effect: { risk_add: 30, controls_add: [C_HUMAN_OVERSIGHT, C_FRIA] }
|
||||
severity: WARN
|
||||
rationale: "Autonomes Fahren ist sicherheitskritisch und hochreguliert"
|
||||
|
||||
- id: R-AUTO-002
|
||||
category: "K. Automotive Hochrisiko"
|
||||
title: "Sicherheitsrelevant ohne Functional Safety"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "automotive_context.safety_relevant"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "automotive_context.functional_safety"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect: { risk_add: 25, feasibility: CONDITIONAL }
|
||||
severity: WARN
|
||||
rationale: "Sicherheitsrelevante Systeme erfordern ISO 26262 Konformitaet"
|
||||
|
||||
# Retail / E-Commerce
|
||||
- id: R-RET-001
|
||||
category: "K. Retail"
|
||||
title: "Personalisierte Preise durch KI"
|
||||
condition: { field: "retail_context.pricing_personalized", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, controls_add: [C_TRANSPARENCY] }
|
||||
severity: WARN
|
||||
rationale: "Personalisierte Preise koennen Verbraucher benachteiligen (DSA Art. 25)"
|
||||
|
||||
- id: R-RET-002
|
||||
category: "K. Retail"
|
||||
title: "Bonitaetspruefung bei Kauf"
|
||||
condition: { field: "retail_context.credit_scoring", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, dsfa_recommended: true, art22_risk: true }
|
||||
severity: WARN
|
||||
rationale: "Kredit-Scoring ist Annex III Nr. 5 AI Act (Zugang zu Diensten)"
|
||||
|
||||
- id: R-RET-003
|
||||
category: "K. Retail"
|
||||
title: "Dark Patterns moeglich"
|
||||
condition: { field: "retail_context.dark_patterns", operator: "equals", value: true }
|
||||
effect: { risk_add: 15 }
|
||||
severity: WARN
|
||||
rationale: "Manipulative UI-Muster verstossen gegen DSA und Verbraucherrecht"
|
||||
|
||||
# IT / Cybersecurity / Telecom
|
||||
- id: R-ITS-001
|
||||
category: "K. IT-Sicherheit"
|
||||
title: "KI-gestuetzte Mitarbeiterueberwachung"
|
||||
condition: { field: "it_security_context.employee_surveillance", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Mitarbeiterueberwachung ist §87 BetrVG + DSGVO relevant"
|
||||
|
||||
- id: R-ITS-002
|
||||
category: "K. IT-Sicherheit"
|
||||
title: "Umfangreiche Log-Speicherung"
|
||||
condition: { field: "it_security_context.data_retention_logs", operator: "equals", value: true }
|
||||
effect: { risk_add: 10, controls_add: [C_DATA_MINIMIZATION] }
|
||||
severity: INFO
|
||||
rationale: "Datenminimierung beachten auch bei Security-Logs"
|
||||
|
||||
# Logistics
|
||||
- id: R-LOG-001
|
||||
category: "K. Logistik"
|
||||
title: "Fahrer-/Kurier-Tracking"
|
||||
condition: { field: "logistics_context.driver_tracking", operator: "equals", value: true }
|
||||
effect: { risk_add: 20 }
|
||||
severity: WARN
|
||||
rationale: "GPS-Tracking ist Verhaltenskontrolle (§87 BetrVG)"
|
||||
|
||||
- id: R-LOG-002
|
||||
category: "K. Logistik"
|
||||
title: "Leistungsbewertung Lagerarbeiter"
|
||||
condition: { field: "logistics_context.workload_scoring", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, art22_risk: true }
|
||||
severity: WARN
|
||||
rationale: "Leistungs-Scoring ist Annex III Nr. 4 (Employment)"
|
||||
|
||||
# Construction / Real Estate
|
||||
- id: R-CON-001
|
||||
category: "K. Bau/Immobilien"
|
||||
title: "KI-gestuetzte Mieterauswahl"
|
||||
condition: { field: "construction_context.tenant_screening", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Mieterauswahl betrifft Zugang zu Wohnraum (Grundrecht)"
|
||||
|
||||
- id: R-CON-002
|
||||
category: "K. Bau/Immobilien"
|
||||
title: "KI-Arbeitsschutzueberwachung"
|
||||
condition: { field: "construction_context.worker_safety", operator: "equals", value: true }
|
||||
effect: { risk_add: 15 }
|
||||
severity: WARN
|
||||
rationale: "Arbeitsschutzueberwachung kann Verhaltenskontrolle sein"
|
||||
|
||||
# Marketing / Media
|
||||
- id: R-MKT-001
|
||||
category: "K. Marketing/Medien"
|
||||
title: "Deepfake-Inhalte ohne Kennzeichnung"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "marketing_context.deepfake_content"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "marketing_context.ai_content_labeled"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect: { risk_add: 20, feasibility: NO }
|
||||
severity: BLOCK
|
||||
rationale: "Art. 50 Abs. 4 AI Act: Deepfakes muessen gekennzeichnet werden"
|
||||
|
||||
- id: R-MKT-002
|
||||
category: "K. Marketing/Medien"
|
||||
title: "Minderjaehrige als Zielgruppe"
|
||||
condition: { field: "marketing_context.minors_targeted", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, controls_add: [C_DSFA] }
|
||||
severity: WARN
|
||||
rationale: "Besonderer Schutz Minderjaehriger (DSA + DSGVO)"
|
||||
|
||||
- id: R-MKT-003
|
||||
category: "K. Marketing/Medien"
|
||||
title: "Verhaltensbasiertes Targeting"
|
||||
condition: { field: "marketing_context.behavioral_targeting", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Behavioral Targeting ist Profiling (Art. 22 DSGVO)"
|
||||
|
||||
# Manufacturing / CE
|
||||
- id: R-MFG-001
|
||||
category: "K. Fertigung"
|
||||
title: "KI in Maschinensicherheit ohne Validierung"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "manufacturing_context.machine_safety"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "manufacturing_context.safety_validated"
|
||||
operator: "equals"
|
||||
value: false
|
||||
effect: { risk_add: 30, feasibility: NO }
|
||||
severity: BLOCK
|
||||
rationale: "Maschinenverordnung (EU) 2023/1230 erfordert Sicherheitsvalidierung"
|
||||
|
||||
- id: R-MFG-002
|
||||
category: "K. Fertigung"
|
||||
title: "CE-Kennzeichnung erforderlich"
|
||||
condition: { field: "manufacturing_context.ce_marking_required", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, controls_add: [C_CE_CONFORMITY] }
|
||||
severity: WARN
|
||||
rationale: "CE-Kennzeichnung ist Pflicht fuer Maschinenprodukte mit KI"
|
||||
|
||||
# Agriculture
|
||||
- id: R-AGR-001
|
||||
category: "K. Landwirtschaft"
|
||||
title: "KI steuert Pestizideinsatz"
|
||||
condition: { field: "agriculture_context.pesticide_ai", operator: "equals", value: true }
|
||||
effect: { risk_add: 15 }
|
||||
severity: WARN
|
||||
rationale: "Umwelt- und Gesundheitsrisiken bei KI-gesteuertem Pflanzenschutz"
|
||||
|
||||
- id: R-AGR-002
|
||||
category: "K. Landwirtschaft"
|
||||
title: "KI beeinflusst Tierhaltung"
|
||||
condition: { field: "agriculture_context.animal_welfare", operator: "equals", value: true }
|
||||
effect: { risk_add: 10 }
|
||||
severity: INFO
|
||||
rationale: "Tierschutzrelevanz bei automatisierter Haltungsentscheidung"
|
||||
|
||||
# Social Services
|
||||
- id: R-SOC-001
|
||||
category: "K. Soziales"
|
||||
title: "KI trifft Leistungsentscheidungen fuer schutzbeduerftiger Gruppen"
|
||||
condition:
|
||||
all_of:
|
||||
- field: "social_services_context.vulnerable_groups"
|
||||
operator: "equals"
|
||||
value: true
|
||||
- field: "social_services_context.benefit_decision"
|
||||
operator: "equals"
|
||||
value: true
|
||||
effect: { risk_add: 25, dsfa_recommended: true, controls_add: [C_FRIA, C_HUMAN_OVERSIGHT] }
|
||||
severity: WARN
|
||||
rationale: "Leistungsentscheidungen fuer Schutzbeduerftiger erfordern FRIA"
|
||||
|
||||
- id: R-SOC-002
|
||||
category: "K. Soziales"
|
||||
title: "KI in Fallmanagement"
|
||||
condition: { field: "social_services_context.case_management", operator: "equals", value: true }
|
||||
effect: { risk_add: 15 }
|
||||
severity: WARN
|
||||
rationale: "Fallmanagement betrifft Grundrechte der Betroffenen"
|
||||
|
||||
# Hospitality / Tourism
|
||||
- id: R-HOS-001
|
||||
category: "K. Tourismus"
|
||||
title: "Dynamische Preisgestaltung"
|
||||
condition: { field: "hospitality_context.dynamic_pricing", operator: "equals", value: true }
|
||||
effect: { risk_add: 10, controls_add: [C_TRANSPARENCY] }
|
||||
severity: INFO
|
||||
rationale: "Personalisierte Preise erfordern Transparenz"
|
||||
|
||||
- id: R-HOS-002
|
||||
category: "K. Tourismus"
|
||||
title: "KI manipuliert Bewertungen"
|
||||
condition: { field: "hospitality_context.review_manipulation", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, feasibility: NO }
|
||||
severity: BLOCK
|
||||
rationale: "Bewertungsmanipulation verstoesst gegen UWG und DSA"
|
||||
|
||||
# Insurance
|
||||
- id: R-INS-001
|
||||
category: "K. Versicherung"
|
||||
title: "KI-gestuetzte Praemienberechnung"
|
||||
condition: { field: "insurance_context.premium_calculation", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Individuelle Praemien koennen diskriminierend wirken (AGG, Annex III Nr. 5)"
|
||||
|
||||
- id: R-INS-002
|
||||
category: "K. Versicherung"
|
||||
title: "Automatisierte Schadenbearbeitung"
|
||||
condition: { field: "insurance_context.claims_automation", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, art22_risk: true }
|
||||
severity: WARN
|
||||
rationale: "Automatische Schadensablehnung kann Art. 22 DSGVO ausloesen"
|
||||
|
||||
# Investment
|
||||
- id: R-INV-001
|
||||
category: "K. Investment"
|
||||
title: "Algorithmischer Handel"
|
||||
condition: { field: "investment_context.algo_trading", operator: "equals", value: true }
|
||||
effect: { risk_add: 15 }
|
||||
severity: WARN
|
||||
rationale: "MiFID II Anforderungen an algorithmischen Handel"
|
||||
|
||||
- id: R-INV-002
|
||||
category: "K. Investment"
|
||||
title: "KI-gestuetzte Anlageberatung (Robo Advisor)"
|
||||
condition: { field: "investment_context.robo_advisor", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, controls_add: [C_HUMAN_OVERSIGHT, C_TRANSPARENCY] }
|
||||
severity: WARN
|
||||
rationale: "Anlageberatung ist reguliert (WpHG, MiFID II) — Haftungsrisiko"
|
||||
|
||||
# Defense
|
||||
- id: R-DEF-001
|
||||
category: "K. Verteidigung"
|
||||
title: "Dual-Use KI-Technologie"
|
||||
condition: { field: "defense_context.dual_use", operator: "equals", value: true }
|
||||
effect: { risk_add: 25 }
|
||||
severity: WARN
|
||||
rationale: "Dual-Use Technologie unterliegt Exportkontrolle (EU VO 2021/821)"
|
||||
|
||||
- id: R-DEF-002
|
||||
category: "K. Verteidigung"
|
||||
title: "Verschlusssachen in KI verarbeitet"
|
||||
condition: { field: "defense_context.classified_data", operator: "equals", value: true }
|
||||
effect: { risk_add: 20, controls_add: [C_ENCRYPTION] }
|
||||
severity: WARN
|
||||
rationale: "VS-NfD und hoeher erfordert besondere Schutzmassnahmen"
|
||||
|
||||
# Supply Chain (LkSG)
|
||||
- id: R-SCH-001
|
||||
category: "K. Lieferkette"
|
||||
title: "KI-Menschenrechtspruefung in Lieferkette"
|
||||
condition: { field: "supply_chain_context.human_rights_check", operator: "equals", value: true }
|
||||
effect: { risk_add: 10 }
|
||||
severity: INFO
|
||||
rationale: "LkSG-relevante KI-Analyse — Bias bei Laenderrisiko-Bewertung beachten"
|
||||
|
||||
- id: R-SCH-002
|
||||
category: "K. Lieferkette"
|
||||
title: "KI ueberwacht Lieferanten"
|
||||
condition: { field: "supply_chain_context.supplier_monitoring", operator: "equals", value: true }
|
||||
effect: { risk_add: 10 }
|
||||
severity: INFO
|
||||
rationale: "Lieferantenbewertung durch KI kann indirekt Personen betreffen"
|
||||
|
||||
# Facility Management
|
||||
- id: R-FAC-001
|
||||
category: "K. Facility"
|
||||
title: "KI-Zutrittskontrolle"
|
||||
condition: { field: "facility_context.access_control_ai", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Biometrische oder verhaltensbasierte Zutrittskontrolle ist DSGVO-relevant"
|
||||
|
||||
- id: R-FAC-002
|
||||
category: "K. Facility"
|
||||
title: "Belegungsueberwachung"
|
||||
condition: { field: "facility_context.occupancy_tracking", operator: "equals", value: true }
|
||||
effect: { risk_add: 10 }
|
||||
severity: INFO
|
||||
rationale: "Belegungsdaten koennen Rueckschluesse auf Verhalten erlauben"
|
||||
|
||||
# Sports
|
||||
- id: R-SPO-001
|
||||
category: "K. Sport"
|
||||
title: "Athleten-Performance-Tracking"
|
||||
condition: { field: "sports_context.athlete_tracking", operator: "equals", value: true }
|
||||
effect: { risk_add: 15 }
|
||||
severity: WARN
|
||||
rationale: "Leistungsdaten von Athleten sind besonders schuetzenswert"
|
||||
|
||||
- id: R-SPO-002
|
||||
category: "K. Sport"
|
||||
title: "Fan-/Zuschauer-Profilbildung"
|
||||
condition: { field: "sports_context.fan_profiling", operator: "equals", value: true }
|
||||
effect: { risk_add: 15, dsfa_recommended: true }
|
||||
severity: WARN
|
||||
rationale: "Massen-Profiling bei Sportevents erfordert DSFA"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# G. Aggregation & Ergebnis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,13 +10,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
COPY requirements.txt requirements-reranker.txt ./
|
||||
|
||||
# Create virtual environment and install dependencies
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir -r requirements-reranker.txt || \
|
||||
echo "WARNING: reranker dependencies not installed (torch/sentence-transformers)"
|
||||
|
||||
# ---- Runtime stage ----
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
@@ -6,6 +6,8 @@ from .routes import router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_failed_routers: dict[str, str] = {}
|
||||
|
||||
|
||||
def _safe_import_router(module_name: str, attr: str = "router"):
|
||||
"""Import a router module safely — log error but don't crash the whole app."""
|
||||
@@ -14,6 +16,7 @@ def _safe_import_router(module_name: str, attr: str = "router"):
|
||||
return getattr(mod, attr)
|
||||
except Exception as e:
|
||||
logger.error("Failed to import %s: %s", module_name, e)
|
||||
_failed_routers[module_name] = str(e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -56,6 +59,10 @@ _ROUTER_MODULES = [
|
||||
"crosswalk_routes",
|
||||
"process_task_routes",
|
||||
"evidence_check_routes",
|
||||
"vvt_library_routes",
|
||||
"tom_mapping_routes",
|
||||
"llm_audit_routes",
|
||||
"assertion_routes",
|
||||
]
|
||||
|
||||
_loaded_count = 0
|
||||
|
||||
227
backend-compliance/compliance/api/assertion_routes.py
Normal file
227
backend-compliance/compliance/api/assertion_routes.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
API routes for Assertion Engine (Anti-Fake-Evidence Phase 2).
|
||||
|
||||
Endpoints:
|
||||
- /assertions: CRUD for assertions
|
||||
- /assertions/extract: Automatic extraction from entity text
|
||||
- /assertions/summary: Stats (total assertions, facts, unverified)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
from ..db.models import AssertionDB
|
||||
from ..services.assertion_engine import extract_assertions
|
||||
from .schemas import (
|
||||
AssertionCreate,
|
||||
AssertionUpdate,
|
||||
AssertionResponse,
|
||||
AssertionListResponse,
|
||||
AssertionSummaryResponse,
|
||||
AssertionExtractRequest,
|
||||
)
|
||||
from .audit_trail_utils import log_audit_trail, generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["compliance-assertions"])
|
||||
|
||||
|
||||
def _build_assertion_response(a: AssertionDB) -> AssertionResponse:
|
||||
return AssertionResponse(
|
||||
id=a.id,
|
||||
tenant_id=a.tenant_id,
|
||||
entity_type=a.entity_type,
|
||||
entity_id=a.entity_id,
|
||||
sentence_text=a.sentence_text,
|
||||
sentence_index=a.sentence_index,
|
||||
assertion_type=a.assertion_type,
|
||||
evidence_ids=a.evidence_ids or [],
|
||||
confidence=a.confidence or 0.0,
|
||||
normative_tier=a.normative_tier,
|
||||
verified_by=a.verified_by,
|
||||
verified_at=a.verified_at,
|
||||
created_at=a.created_at,
|
||||
updated_at=a.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/assertions", response_model=AssertionResponse)
|
||||
async def create_assertion(
|
||||
data: AssertionCreate,
|
||||
tenant_id: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a single assertion manually."""
|
||||
a = AssertionDB(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
entity_type=data.entity_type,
|
||||
entity_id=data.entity_id,
|
||||
sentence_text=data.sentence_text,
|
||||
assertion_type=data.assertion_type or "assertion",
|
||||
evidence_ids=data.evidence_ids or [],
|
||||
normative_tier=data.normative_tier,
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return _build_assertion_response(a)
|
||||
|
||||
|
||||
@router.get("/assertions", response_model=AssertionListResponse)
|
||||
async def list_assertions(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
assertion_type: Optional[str] = Query(None),
|
||||
tenant_id: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List assertions with optional filters."""
|
||||
query = db.query(AssertionDB)
|
||||
if entity_type:
|
||||
query = query.filter(AssertionDB.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(AssertionDB.entity_id == entity_id)
|
||||
if assertion_type:
|
||||
query = query.filter(AssertionDB.assertion_type == assertion_type)
|
||||
if tenant_id:
|
||||
query = query.filter(AssertionDB.tenant_id == tenant_id)
|
||||
|
||||
total = query.count()
|
||||
records = query.order_by(AssertionDB.sentence_index.asc()).limit(limit).all()
|
||||
|
||||
return AssertionListResponse(
|
||||
assertions=[_build_assertion_response(a) for a in records],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assertions/summary", response_model=AssertionSummaryResponse)
|
||||
async def assertion_summary(
|
||||
tenant_id: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Summary stats: total assertions, facts, rationale, unverified."""
|
||||
query = db.query(AssertionDB)
|
||||
if tenant_id:
|
||||
query = query.filter(AssertionDB.tenant_id == tenant_id)
|
||||
if entity_type:
|
||||
query = query.filter(AssertionDB.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(AssertionDB.entity_id == entity_id)
|
||||
|
||||
all_records = query.all()
|
||||
|
||||
total = len(all_records)
|
||||
facts = sum(1 for a in all_records if a.assertion_type == "fact")
|
||||
rationale = sum(1 for a in all_records if a.assertion_type == "rationale")
|
||||
unverified = sum(1 for a in all_records if a.assertion_type == "assertion" and not a.verified_by)
|
||||
|
||||
return AssertionSummaryResponse(
|
||||
total_assertions=total,
|
||||
total_facts=facts,
|
||||
total_rationale=rationale,
|
||||
unverified_count=unverified,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assertions/{assertion_id}", response_model=AssertionResponse)
|
||||
async def get_assertion(
|
||||
assertion_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a single assertion by ID."""
|
||||
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
|
||||
return _build_assertion_response(a)
|
||||
|
||||
|
||||
@router.put("/assertions/{assertion_id}", response_model=AssertionResponse)
|
||||
async def update_assertion(
|
||||
assertion_id: str,
|
||||
data: AssertionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update an assertion (e.g. link evidence, change type)."""
|
||||
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
|
||||
|
||||
update_fields = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_fields.items():
|
||||
setattr(a, key, value)
|
||||
a.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return _build_assertion_response(a)
|
||||
|
||||
|
||||
@router.post("/assertions/{assertion_id}/verify", response_model=AssertionResponse)
|
||||
async def verify_assertion(
|
||||
assertion_id: str,
|
||||
verified_by: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Mark an assertion as verified fact."""
|
||||
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
|
||||
|
||||
a.assertion_type = "fact"
|
||||
a.verified_by = verified_by
|
||||
a.verified_at = datetime.utcnow()
|
||||
a.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
return _build_assertion_response(a)
|
||||
|
||||
|
||||
@router.post("/assertions/extract", response_model=AssertionListResponse)
|
||||
async def extract_assertions_endpoint(
|
||||
data: AssertionExtractRequest,
|
||||
tenant_id: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Extract assertions from free text and persist them."""
|
||||
extracted = extract_assertions(
|
||||
text=data.text,
|
||||
entity_type=data.entity_type,
|
||||
entity_id=data.entity_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
created = []
|
||||
for item in extracted:
|
||||
a = AssertionDB(
|
||||
id=generate_id(),
|
||||
tenant_id=item["tenant_id"],
|
||||
entity_type=item["entity_type"],
|
||||
entity_id=item["entity_id"],
|
||||
sentence_text=item["sentence_text"],
|
||||
sentence_index=item["sentence_index"],
|
||||
assertion_type=item["assertion_type"],
|
||||
evidence_ids=item["evidence_ids"],
|
||||
normative_tier=item.get("normative_tier"),
|
||||
confidence=item.get("confidence", 0.0),
|
||||
)
|
||||
db.add(a)
|
||||
created.append(a)
|
||||
|
||||
db.commit()
|
||||
for a in created:
|
||||
db.refresh(a)
|
||||
|
||||
return AssertionListResponse(
|
||||
assertions=[_build_assertion_response(a) for a in created],
|
||||
total=len(created),
|
||||
)
|
||||
53
backend-compliance/compliance/api/audit_trail_utils.py
Normal file
53
backend-compliance/compliance/api/audit_trail_utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Shared audit trail utilities.
|
||||
|
||||
Extracted from isms_routes.py for reuse across evidence, control,
|
||||
and assertion routes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db.models import AuditTrailDB
|
||||
|
||||
|
||||
def generate_id() -> str:
|
||||
"""Generate a UUID string."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def create_signature(data: str) -> str:
|
||||
"""Create SHA-256 signature."""
|
||||
return hashlib.sha256(data.encode()).hexdigest()
|
||||
|
||||
|
||||
def log_audit_trail(
|
||||
db: Session,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
entity_name: str,
|
||||
action: str,
|
||||
performed_by: str,
|
||||
field_changed: str = None,
|
||||
old_value: str = None,
|
||||
new_value: str = None,
|
||||
change_summary: str = None,
|
||||
):
|
||||
"""Log an entry to the audit trail."""
|
||||
trail = AuditTrailDB(
|
||||
id=generate_id(),
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
action=action,
|
||||
field_changed=field_changed,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
change_summary=change_summary,
|
||||
performed_by=performed_by,
|
||||
performed_at=datetime.utcnow(),
|
||||
checksum=create_signature(f"{entity_type}|{entity_id}|{action}|{performed_by}"),
|
||||
)
|
||||
db.add(trail)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ from compliance.services.control_generator import (
|
||||
ALL_COLLECTIONS,
|
||||
VALID_CATEGORIES,
|
||||
VALID_DOMAINS,
|
||||
_classify_regulation,
|
||||
_detect_category,
|
||||
_detect_domain,
|
||||
_llm_local,
|
||||
@@ -978,3 +979,122 @@ async def get_domain_backfill_status(backfill_id: str):
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Domain backfill job not found")
|
||||
return status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-Type Backfill — Classify law vs guideline vs standard vs restricted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SourceTypeBackfillRequest(BaseModel):
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
_source_type_backfill_status: dict = {}
|
||||
|
||||
|
||||
async def _run_source_type_backfill(dry_run: bool, backfill_id: str):
|
||||
"""Backfill source_type into source_citation JSONB for all controls."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Find controls with source_citation that lack source_type
|
||||
rows = db.execute(text("""
|
||||
SELECT control_id, source_citation, generation_metadata
|
||||
FROM compliance.canonical_controls
|
||||
WHERE source_citation IS NOT NULL
|
||||
AND (source_citation->>'source_type' IS NULL
|
||||
OR source_citation->>'source_type' = '')
|
||||
""")).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
updated = 0
|
||||
already_correct = 0
|
||||
errors = []
|
||||
|
||||
_source_type_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "updated": 0, "dry_run": dry_run,
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
cid = row[0]
|
||||
citation = row[1] if isinstance(row[1], dict) else json.loads(row[1] or "{}")
|
||||
metadata = row[2] if isinstance(row[2], dict) else json.loads(row[2] or "{}")
|
||||
|
||||
# Get regulation_code from metadata
|
||||
reg_code = metadata.get("source_regulation", "")
|
||||
if not reg_code:
|
||||
# Try to infer from source name
|
||||
errors.append(f"{cid}: no source_regulation in metadata")
|
||||
continue
|
||||
|
||||
# Classify
|
||||
license_info = _classify_regulation(reg_code)
|
||||
source_type = license_info.get("source_type", "restricted")
|
||||
|
||||
# Update citation
|
||||
citation["source_type"] = source_type
|
||||
|
||||
if not dry_run:
|
||||
db.execute(text("""
|
||||
UPDATE compliance.canonical_controls
|
||||
SET source_citation = :citation
|
||||
WHERE control_id = :cid
|
||||
"""), {"citation": json.dumps(citation), "cid": cid})
|
||||
if updated % 100 == 0:
|
||||
db.commit()
|
||||
updated += 1
|
||||
|
||||
if not dry_run:
|
||||
db.commit()
|
||||
|
||||
# Count distribution
|
||||
dist_query = db.execute(text("""
|
||||
SELECT source_citation->>'source_type' as st, COUNT(*)
|
||||
FROM compliance.canonical_controls
|
||||
WHERE source_citation IS NOT NULL
|
||||
AND source_citation->>'source_type' IS NOT NULL
|
||||
GROUP BY st
|
||||
""")).fetchall() if not dry_run else []
|
||||
|
||||
distribution = {r[0]: r[1] for r in dist_query}
|
||||
|
||||
_source_type_backfill_status[backfill_id] = {
|
||||
"status": "completed", "total": total, "updated": updated,
|
||||
"dry_run": dry_run, "distribution": distribution,
|
||||
"errors": errors[:50],
|
||||
}
|
||||
logger.info("Source-type backfill %s completed: %d/%d updated (dry_run=%s)",
|
||||
backfill_id, updated, total, dry_run)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Source-type backfill %s failed: %s", backfill_id, e)
|
||||
_source_type_backfill_status[backfill_id] = {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate/backfill-source-type")
|
||||
async def start_source_type_backfill(req: SourceTypeBackfillRequest):
|
||||
"""Backfill source_type (law/guideline/standard/restricted) into source_citation JSONB.
|
||||
|
||||
Classifies each control's source as binding law, authority guideline,
|
||||
voluntary standard, or restricted norm based on regulation_code.
|
||||
Default is dry_run=True (preview only).
|
||||
"""
|
||||
import uuid
|
||||
backfill_id = str(uuid.uuid4())[:8]
|
||||
_source_type_backfill_status[backfill_id] = {"status": "starting"}
|
||||
asyncio.create_task(_run_source_type_backfill(req.dry_run, backfill_id))
|
||||
return {
|
||||
"status": "running",
|
||||
"backfill_id": backfill_id,
|
||||
"message": f"Source-type backfill started. Poll /generate/source-type-backfill-status/{backfill_id}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/generate/source-type-backfill-status/{backfill_id}")
|
||||
async def get_source_type_backfill_status(backfill_id: str):
|
||||
"""Get status of a source-type backfill job."""
|
||||
status = _source_type_backfill_status.get(backfill_id)
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Source-type backfill job not found")
|
||||
return status
|
||||
|
||||
@@ -13,6 +13,8 @@ Endpoints:
|
||||
GET /v1/canonical/crosswalk/stats — Coverage statistics
|
||||
|
||||
POST /v1/canonical/migrate/decompose — Pass 0a: Obligation extraction
|
||||
POST /v1/canonical/migrate/merge-obligations — Merge implementation-level dupes
|
||||
POST /v1/canonical/migrate/enrich-obligations — Add trigger_type, impl metadata
|
||||
POST /v1/canonical/migrate/compose-atomic — Pass 0b: Atomic control composition
|
||||
POST /v1/canonical/migrate/link-obligations — Pass 1: Obligation linkage
|
||||
POST /v1/canonical/migrate/classify-patterns — Pass 2: Pattern classification
|
||||
@@ -157,6 +159,9 @@ class DecompositionStatusResponse(BaseModel):
|
||||
rejected: int = 0
|
||||
composed: int = 0
|
||||
atomic_controls: int = 0
|
||||
merged: int = 0
|
||||
enriched: int = 0
|
||||
ready_for_pass0b: int = 0
|
||||
decomposition_pct: float = 0.0
|
||||
composition_pct: float = 0.0
|
||||
|
||||
@@ -488,6 +493,50 @@ async def migrate_decompose(req: MigrationRequest):
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/migrate/merge-obligations", response_model=MigrationResponse)
|
||||
async def migrate_merge_obligations():
|
||||
"""Merge implementation-level duplicate obligations within each parent.
|
||||
|
||||
Run AFTER Pass 0a, BEFORE Pass 0b. No LLM calls — rule-based.
|
||||
Merges obligations that share similar action+object into the more
|
||||
abstract survivor, marking the concrete duplicate as 'merged'.
|
||||
"""
|
||||
from compliance.services.decomposition_pass import DecompositionPass
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
decomp = DecompositionPass(db=db)
|
||||
stats = decomp.run_merge_pass()
|
||||
return MigrationResponse(status="completed", stats=stats)
|
||||
except Exception as e:
|
||||
logger.error("Merge pass failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/migrate/enrich-obligations", response_model=MigrationResponse)
|
||||
async def migrate_enrich_obligations():
|
||||
"""Add trigger_type and is_implementation_specific metadata.
|
||||
|
||||
Run AFTER merge pass, BEFORE Pass 0b. No LLM calls — rule-based.
|
||||
Classifies trigger_type (event/periodic/continuous) from obligation text
|
||||
and detects implementation-specific obligations (concrete tools/protocols).
|
||||
"""
|
||||
from compliance.services.decomposition_pass import DecompositionPass
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
decomp = DecompositionPass(db=db)
|
||||
stats = decomp.enrich_obligations()
|
||||
return MigrationResponse(status="completed", stats=stats)
|
||||
except Exception as e:
|
||||
logger.error("Enrich pass failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/migrate/compose-atomic", response_model=MigrationResponse)
|
||||
async def migrate_compose_atomic(req: MigrationRequest):
|
||||
"""Pass 0b: Compose atomic controls from obligation candidates.
|
||||
@@ -715,6 +764,75 @@ async def decomposition_status():
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BATCH DEDUP ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Module-level runner reference for status polling
|
||||
_batch_dedup_runner = None
|
||||
|
||||
|
||||
@router.post("/migrate/batch-dedup", response_model=MigrationResponse)
|
||||
async def migrate_batch_dedup(
|
||||
dry_run: bool = Query(False, description="Preview mode — no DB changes"),
|
||||
hint_filter: Optional[str] = Query(None, description="Only process hints matching this prefix"),
|
||||
):
|
||||
"""Batch dedup: reduce ~85k Pass 0b controls to ~18-25k masters.
|
||||
|
||||
Phase 1: Groups by merge_group_hint, picks best quality master, links rest.
|
||||
Phase 2: Cross-group embedding search for semantically similar masters.
|
||||
"""
|
||||
global _batch_dedup_runner
|
||||
from compliance.services.batch_dedup_runner import BatchDedupRunner
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
runner = BatchDedupRunner(db=db)
|
||||
_batch_dedup_runner = runner
|
||||
stats = await runner.run(dry_run=dry_run, hint_filter=hint_filter)
|
||||
return MigrationResponse(status="completed", stats=stats)
|
||||
except Exception as e:
|
||||
logger.error("Batch dedup failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
_batch_dedup_runner = None
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/migrate/batch-dedup/status")
|
||||
async def batch_dedup_status():
|
||||
"""Get current batch dedup progress (while running)."""
|
||||
if _batch_dedup_runner is not None:
|
||||
return {"running": True, **_batch_dedup_runner.get_status()}
|
||||
|
||||
# Not running — show DB stats
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(text("""
|
||||
SELECT
|
||||
count(*) FILTER (WHERE decomposition_method = 'pass0b') AS total_pass0b,
|
||||
count(*) FILTER (WHERE decomposition_method = 'pass0b'
|
||||
AND release_state = 'duplicate') AS duplicates,
|
||||
count(*) FILTER (WHERE decomposition_method = 'pass0b'
|
||||
AND release_state != 'duplicate'
|
||||
AND release_state != 'deprecated') AS masters
|
||||
FROM canonical_controls
|
||||
""")).fetchone()
|
||||
review_count = db.execute(text(
|
||||
"SELECT count(*) FROM control_dedup_reviews WHERE review_status = 'pending'"
|
||||
)).fetchone()[0]
|
||||
return {
|
||||
"running": False,
|
||||
"total_pass0b": row[0],
|
||||
"duplicates": row[1],
|
||||
"masters": row[2],
|
||||
"pending_reviews": review_count,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
@@ -32,14 +32,21 @@ from ..db import (
|
||||
ControlRepository,
|
||||
EvidenceRepository,
|
||||
RiskRepository,
|
||||
AssertionDB,
|
||||
)
|
||||
from .schemas import (
|
||||
DashboardResponse,
|
||||
MultiDimensionalScore,
|
||||
ExecutiveDashboardResponse,
|
||||
TrendDataPoint,
|
||||
RiskSummary,
|
||||
DeadlineItem,
|
||||
TeamWorkloadItem,
|
||||
TraceabilityAssertion,
|
||||
TraceabilityEvidence,
|
||||
TraceabilityCoverage,
|
||||
TraceabilityControl,
|
||||
TraceabilityMatrixResponse,
|
||||
)
|
||||
from .tenant_utils import get_tenant_id as _get_tenant_id
|
||||
from .db_utils import row_to_dict as _row_to_dict
|
||||
@@ -95,6 +102,14 @@ async def get_dashboard(db: Session = Depends(get_db)):
|
||||
# or compute from by_status dict
|
||||
score = ctrl_stats.get("compliance_score", 0.0)
|
||||
|
||||
# Multi-dimensional score (Anti-Fake-Evidence)
|
||||
try:
|
||||
ms = ctrl_repo.get_multi_dimensional_score()
|
||||
multi_score = MultiDimensionalScore(**ms)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute multi-dimensional score: {e}")
|
||||
multi_score = None
|
||||
|
||||
return DashboardResponse(
|
||||
compliance_score=round(score, 1),
|
||||
total_regulations=len(regulations),
|
||||
@@ -107,6 +122,7 @@ async def get_dashboard(db: Session = Depends(get_db)):
|
||||
total_risks=len(risks),
|
||||
risks_by_level=risks_by_level,
|
||||
recent_activity=[],
|
||||
multi_score=multi_score,
|
||||
)
|
||||
|
||||
|
||||
@@ -125,11 +141,18 @@ async def get_compliance_score(db: Session = Depends(get_db)):
|
||||
else:
|
||||
score = 0
|
||||
|
||||
# Multi-dimensional score (Anti-Fake-Evidence)
|
||||
try:
|
||||
multi_score = ctrl_repo.get_multi_dimensional_score()
|
||||
except Exception:
|
||||
multi_score = None
|
||||
|
||||
return {
|
||||
"score": round(score, 1),
|
||||
"total_controls": total,
|
||||
"passing_controls": passing,
|
||||
"partial_controls": partial,
|
||||
"multi_score": multi_score,
|
||||
}
|
||||
|
||||
|
||||
@@ -597,6 +620,158 @@ async def get_score_history(
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Evidence Distribution (Anti-Fake-Evidence Phase 3)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/dashboard/evidence-distribution")
|
||||
async def get_evidence_distribution(
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
"""Evidence counts by confidence level and four-eyes status."""
|
||||
evidence_repo = EvidenceRepository(db)
|
||||
all_evidence = evidence_repo.get_all()
|
||||
|
||||
by_confidence = {"E0": 0, "E1": 0, "E2": 0, "E3": 0, "E4": 0}
|
||||
four_eyes_pending = 0
|
||||
|
||||
for e in all_evidence:
|
||||
level = e.confidence_level.value if e.confidence_level else "E1"
|
||||
if level in by_confidence:
|
||||
by_confidence[level] += 1
|
||||
if e.requires_four_eyes and e.approval_status not in ("approved", "rejected"):
|
||||
four_eyes_pending += 1
|
||||
|
||||
return {
|
||||
"by_confidence": by_confidence,
|
||||
"four_eyes_pending": four_eyes_pending,
|
||||
"total": len(all_evidence),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Traceability Matrix (Anti-Fake-Evidence Phase 4a)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/dashboard/traceability-matrix", response_model=TraceabilityMatrixResponse)
|
||||
async def get_traceability_matrix(
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: str = Depends(_get_tenant_id),
|
||||
):
|
||||
"""
|
||||
Full traceability chain: Control → Evidence → Assertions.
|
||||
|
||||
Loads each entity set once, builds in-memory indices, and nests
|
||||
the result so the frontend can render a matrix view.
|
||||
"""
|
||||
ctrl_repo = ControlRepository(db)
|
||||
evidence_repo = EvidenceRepository(db)
|
||||
|
||||
# 1. Load all three entity sets
|
||||
controls = ctrl_repo.get_all()
|
||||
all_evidence = evidence_repo.get_all()
|
||||
all_assertions = db.query(AssertionDB).filter(
|
||||
AssertionDB.entity_type == "evidence",
|
||||
).all()
|
||||
|
||||
# 2. Index assertions by evidence_id (entity_id)
|
||||
assertions_by_evidence: Dict[str, list] = {}
|
||||
for a in all_assertions:
|
||||
assertions_by_evidence.setdefault(a.entity_id, []).append(a)
|
||||
|
||||
# 3. Index evidence by control_id
|
||||
evidence_by_control: Dict[str, list] = {}
|
||||
for e in all_evidence:
|
||||
evidence_by_control.setdefault(str(e.control_id), []).append(e)
|
||||
|
||||
# 4. Build nested response
|
||||
result_controls: list = []
|
||||
total_controls = 0
|
||||
covered_controls = 0
|
||||
fully_verified = 0
|
||||
|
||||
for ctrl in controls:
|
||||
total_controls += 1
|
||||
ctrl_id = str(ctrl.id)
|
||||
ctrl_evidence = evidence_by_control.get(ctrl_id, [])
|
||||
|
||||
nested_evidence: list = []
|
||||
has_evidence = len(ctrl_evidence) > 0
|
||||
has_assertions = False
|
||||
all_verified = True
|
||||
min_conf: Optional[str] = None
|
||||
conf_order = {"E0": 0, "E1": 1, "E2": 2, "E3": 3, "E4": 4}
|
||||
|
||||
for e in ctrl_evidence:
|
||||
ev_id = str(e.id)
|
||||
ev_assertions = assertions_by_evidence.get(ev_id, [])
|
||||
|
||||
nested_assertions = [
|
||||
TraceabilityAssertion(
|
||||
id=str(a.id),
|
||||
sentence_text=a.sentence_text,
|
||||
assertion_type=a.assertion_type or "assertion",
|
||||
confidence=a.confidence or 0.0,
|
||||
verified=a.verified_by is not None,
|
||||
)
|
||||
for a in ev_assertions
|
||||
]
|
||||
|
||||
if nested_assertions:
|
||||
has_assertions = True
|
||||
for na in nested_assertions:
|
||||
if not na.verified:
|
||||
all_verified = False
|
||||
|
||||
conf = e.confidence_level.value if e.confidence_level else "E1"
|
||||
if min_conf is None or conf_order.get(conf, 1) < conf_order.get(min_conf, 1):
|
||||
min_conf = conf
|
||||
|
||||
nested_evidence.append(TraceabilityEvidence(
|
||||
id=ev_id,
|
||||
title=e.title,
|
||||
evidence_type=e.evidence_type,
|
||||
confidence_level=conf,
|
||||
status=e.status.value if e.status else "valid",
|
||||
assertions=nested_assertions,
|
||||
))
|
||||
|
||||
if not has_assertions:
|
||||
all_verified = False
|
||||
|
||||
if has_evidence:
|
||||
covered_controls += 1
|
||||
if has_evidence and has_assertions and all_verified:
|
||||
fully_verified += 1
|
||||
|
||||
coverage = TraceabilityCoverage(
|
||||
has_evidence=has_evidence,
|
||||
has_assertions=has_assertions,
|
||||
all_assertions_verified=all_verified,
|
||||
min_confidence_level=min_conf,
|
||||
)
|
||||
|
||||
result_controls.append(TraceabilityControl(
|
||||
id=ctrl_id,
|
||||
control_id=ctrl.control_id,
|
||||
title=ctrl.title,
|
||||
status=ctrl.status.value if ctrl.status else "planned",
|
||||
domain=ctrl.domain.value if ctrl.domain else "unknown",
|
||||
evidence=nested_evidence,
|
||||
coverage=coverage,
|
||||
))
|
||||
|
||||
summary = {
|
||||
"total_controls": total_controls,
|
||||
"covered_controls": covered_controls,
|
||||
"fully_verified": fully_verified,
|
||||
"uncovered_controls": total_controls - covered_controls,
|
||||
}
|
||||
|
||||
return TraceabilityMatrixResponse(controls=result_controls, summary=summary)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Reports
|
||||
# ============================================================================
|
||||
|
||||
@@ -200,6 +200,9 @@ def _get_tenant_id(tenant_id: Optional[str]) -> str:
|
||||
def _dsfa_to_response(row) -> dict:
|
||||
"""Convert a DB row to a JSON-serializable dict."""
|
||||
import json
|
||||
# SQLAlchemy 2.0: Row objects need ._mapping for string-key access
|
||||
if hasattr(row, "_mapping"):
|
||||
row = row._mapping
|
||||
|
||||
def _parse_arr(val):
|
||||
"""Parse a JSONB array field → list."""
|
||||
@@ -558,8 +561,9 @@ async def create_dsfa(
|
||||
).fetchone()
|
||||
|
||||
db.flush()
|
||||
row_id = row._mapping["id"] if hasattr(row, "_mapping") else row[0]
|
||||
_log_audit(
|
||||
db, tid, row["id"], "CREATE", request.created_by,
|
||||
db, tid, row_id, "CREATE", request.created_by,
|
||||
new_values={"title": request.title, "status": request.status},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -26,17 +26,102 @@ from ..db import (
|
||||
ControlRepository,
|
||||
EvidenceRepository,
|
||||
EvidenceStatusEnum,
|
||||
EvidenceConfidenceEnum,
|
||||
EvidenceTruthStatusEnum,
|
||||
)
|
||||
from ..db.models import EvidenceDB, ControlDB
|
||||
from ..db.models import EvidenceDB, ControlDB, AuditTrailDB
|
||||
from ..services.auto_risk_updater import AutoRiskUpdater
|
||||
from .schemas import (
|
||||
EvidenceCreate, EvidenceResponse, EvidenceListResponse,
|
||||
EvidenceRejectRequest,
|
||||
)
|
||||
from .audit_trail_utils import log_audit_trail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["compliance-evidence"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Anti-Fake-Evidence: Four-Eyes Domain Check
|
||||
# ============================================================================
|
||||
|
||||
FOUR_EYES_DOMAINS = {"gov", "priv"}
|
||||
|
||||
|
||||
def _requires_four_eyes(control_domain: str) -> bool:
|
||||
"""Controls in governance/privacy domains require two independent reviewers."""
|
||||
return control_domain in FOUR_EYES_DOMAINS
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Anti-Fake-Evidence: Auto-Classification Helpers
|
||||
# ============================================================================
|
||||
|
||||
def _classify_confidence(source: Optional[str], evidence_type: Optional[str] = None, artifact_hash: Optional[str] = None) -> EvidenceConfidenceEnum:
|
||||
"""Classify evidence confidence level based on source and metadata."""
|
||||
if source == "ci_pipeline":
|
||||
return EvidenceConfidenceEnum.E3
|
||||
if source == "api" and artifact_hash:
|
||||
return EvidenceConfidenceEnum.E3
|
||||
if source == "api":
|
||||
return EvidenceConfidenceEnum.E3
|
||||
if source in ("manual", "upload"):
|
||||
return EvidenceConfidenceEnum.E1
|
||||
if source == "generated":
|
||||
return EvidenceConfidenceEnum.E0
|
||||
# Default for unknown sources
|
||||
return EvidenceConfidenceEnum.E1
|
||||
|
||||
|
||||
def _classify_truth_status(source: Optional[str]) -> EvidenceTruthStatusEnum:
|
||||
"""Classify evidence truth status based on source."""
|
||||
if source == "ci_pipeline":
|
||||
return EvidenceTruthStatusEnum.OBSERVED
|
||||
if source in ("manual", "upload"):
|
||||
return EvidenceTruthStatusEnum.UPLOADED
|
||||
if source == "generated":
|
||||
return EvidenceTruthStatusEnum.GENERATED
|
||||
if source == "api":
|
||||
return EvidenceTruthStatusEnum.OBSERVED
|
||||
return EvidenceTruthStatusEnum.UPLOADED
|
||||
|
||||
|
||||
def _build_evidence_response(e: EvidenceDB) -> EvidenceResponse:
|
||||
"""Build an EvidenceResponse from an EvidenceDB, including anti-fake fields."""
|
||||
return EvidenceResponse(
|
||||
id=e.id,
|
||||
control_id=e.control_id,
|
||||
evidence_type=e.evidence_type,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
artifact_path=e.artifact_path,
|
||||
artifact_url=e.artifact_url,
|
||||
artifact_hash=e.artifact_hash,
|
||||
file_size_bytes=e.file_size_bytes,
|
||||
mime_type=e.mime_type,
|
||||
valid_from=e.valid_from,
|
||||
valid_until=e.valid_until,
|
||||
status=e.status.value if e.status else None,
|
||||
source=e.source,
|
||||
ci_job_id=e.ci_job_id,
|
||||
uploaded_by=e.uploaded_by,
|
||||
collected_at=e.collected_at,
|
||||
created_at=e.created_at,
|
||||
confidence_level=e.confidence_level.value if e.confidence_level else None,
|
||||
truth_status=e.truth_status.value if e.truth_status else None,
|
||||
generation_mode=e.generation_mode,
|
||||
may_be_used_as_evidence=e.may_be_used_as_evidence,
|
||||
reviewed_by=e.reviewed_by,
|
||||
reviewed_at=e.reviewed_at,
|
||||
approval_status=e.approval_status,
|
||||
first_reviewer=e.first_reviewer,
|
||||
first_reviewed_at=e.first_reviewed_at,
|
||||
second_reviewer=e.second_reviewer,
|
||||
second_reviewed_at=e.second_reviewed_at,
|
||||
requires_four_eyes=e.requires_four_eyes,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Evidence
|
||||
# ============================================================================
|
||||
@@ -80,29 +165,7 @@ async def list_evidence(
|
||||
offset = (page - 1) * limit
|
||||
evidence = evidence[offset:offset + limit]
|
||||
|
||||
results = [
|
||||
EvidenceResponse(
|
||||
id=e.id,
|
||||
control_id=e.control_id,
|
||||
evidence_type=e.evidence_type,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
artifact_path=e.artifact_path,
|
||||
artifact_url=e.artifact_url,
|
||||
artifact_hash=e.artifact_hash,
|
||||
file_size_bytes=e.file_size_bytes,
|
||||
mime_type=e.mime_type,
|
||||
valid_from=e.valid_from,
|
||||
valid_until=e.valid_until,
|
||||
status=e.status.value if e.status else None,
|
||||
source=e.source,
|
||||
ci_job_id=e.ci_job_id,
|
||||
uploaded_by=e.uploaded_by,
|
||||
collected_at=e.collected_at,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
for e in evidence
|
||||
]
|
||||
results = [_build_evidence_response(e) for e in evidence]
|
||||
|
||||
return EvidenceListResponse(evidence=results, total=total)
|
||||
|
||||
@@ -121,6 +184,22 @@ async def create_evidence(
|
||||
if not control:
|
||||
raise HTTPException(status_code=404, detail=f"Control {evidence_data.control_id} not found")
|
||||
|
||||
source = evidence_data.source or "api"
|
||||
confidence = _classify_confidence(source, evidence_data.evidence_type)
|
||||
truth = _classify_truth_status(source)
|
||||
|
||||
# Allow explicit override from request
|
||||
if evidence_data.confidence_level:
|
||||
try:
|
||||
confidence = EvidenceConfidenceEnum(evidence_data.confidence_level)
|
||||
except ValueError:
|
||||
pass
|
||||
if evidence_data.truth_status:
|
||||
try:
|
||||
truth = EvidenceTruthStatusEnum(evidence_data.truth_status)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
evidence = repo.create(
|
||||
control_id=control.id,
|
||||
evidence_type=evidence_data.evidence_type,
|
||||
@@ -129,31 +208,34 @@ async def create_evidence(
|
||||
artifact_url=evidence_data.artifact_url,
|
||||
valid_from=evidence_data.valid_from,
|
||||
valid_until=evidence_data.valid_until,
|
||||
source=evidence_data.source or "api",
|
||||
source=source,
|
||||
ci_job_id=evidence_data.ci_job_id,
|
||||
)
|
||||
|
||||
# Set anti-fake-evidence fields
|
||||
evidence.confidence_level = confidence
|
||||
evidence.truth_status = truth
|
||||
# Generated evidence should not be used as evidence by default
|
||||
if truth == EvidenceTruthStatusEnum.GENERATED:
|
||||
evidence.may_be_used_as_evidence = False
|
||||
|
||||
# Four-Eyes: check if the linked control's domain requires it
|
||||
control_domain = control.domain.value if control.domain else ""
|
||||
if _requires_four_eyes(control_domain):
|
||||
evidence.requires_four_eyes = True
|
||||
evidence.approval_status = "pending_first"
|
||||
|
||||
db.commit()
|
||||
|
||||
# Audit trail
|
||||
log_audit_trail(
|
||||
db, "evidence", evidence.id, evidence.title, "create",
|
||||
performed_by=evidence_data.source or "api",
|
||||
change_summary=f"Evidence created with confidence={confidence.value}, truth={truth.value}",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return EvidenceResponse(
|
||||
id=evidence.id,
|
||||
control_id=evidence.control_id,
|
||||
evidence_type=evidence.evidence_type,
|
||||
title=evidence.title,
|
||||
description=evidence.description,
|
||||
artifact_path=evidence.artifact_path,
|
||||
artifact_url=evidence.artifact_url,
|
||||
artifact_hash=evidence.artifact_hash,
|
||||
file_size_bytes=evidence.file_size_bytes,
|
||||
mime_type=evidence.mime_type,
|
||||
valid_from=evidence.valid_from,
|
||||
valid_until=evidence.valid_until,
|
||||
status=evidence.status.value if evidence.status else None,
|
||||
source=evidence.source,
|
||||
ci_job_id=evidence.ci_job_id,
|
||||
uploaded_by=evidence.uploaded_by,
|
||||
collected_at=evidence.collected_at,
|
||||
created_at=evidence.created_at,
|
||||
)
|
||||
return _build_evidence_response(evidence)
|
||||
|
||||
|
||||
@router.delete("/evidence/{evidence_id}")
|
||||
@@ -223,28 +305,20 @@ async def upload_evidence(
|
||||
mime_type=file.content_type,
|
||||
source="upload",
|
||||
)
|
||||
|
||||
# Upload evidence → E1 + uploaded
|
||||
evidence.confidence_level = EvidenceConfidenceEnum.E1
|
||||
evidence.truth_status = EvidenceTruthStatusEnum.UPLOADED
|
||||
|
||||
# Four-Eyes: check if the linked control's domain requires it
|
||||
control_domain = control.domain.value if control.domain else ""
|
||||
if _requires_four_eyes(control_domain):
|
||||
evidence.requires_four_eyes = True
|
||||
evidence.approval_status = "pending_first"
|
||||
|
||||
db.commit()
|
||||
|
||||
return EvidenceResponse(
|
||||
id=evidence.id,
|
||||
control_id=evidence.control_id,
|
||||
evidence_type=evidence.evidence_type,
|
||||
title=evidence.title,
|
||||
description=evidence.description,
|
||||
artifact_path=evidence.artifact_path,
|
||||
artifact_url=evidence.artifact_url,
|
||||
artifact_hash=evidence.artifact_hash,
|
||||
file_size_bytes=evidence.file_size_bytes,
|
||||
mime_type=evidence.mime_type,
|
||||
valid_from=evidence.valid_from,
|
||||
valid_until=evidence.valid_until,
|
||||
status=evidence.status.value if evidence.status else None,
|
||||
source=evidence.source,
|
||||
ci_job_id=evidence.ci_job_id,
|
||||
uploaded_by=evidence.uploaded_by,
|
||||
collected_at=evidence.collected_at,
|
||||
created_at=evidence.created_at,
|
||||
)
|
||||
return _build_evidence_response(evidence)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -357,7 +431,7 @@ def _store_evidence(
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(report_data or {}, f, indent=2)
|
||||
|
||||
# Create evidence record
|
||||
# Create evidence record with anti-fake-evidence classification
|
||||
evidence = EvidenceDB(
|
||||
id=str(uuid_module.uuid4()),
|
||||
control_id=control_db_id,
|
||||
@@ -373,6 +447,10 @@ def _store_evidence(
|
||||
valid_from=datetime.utcnow(),
|
||||
valid_until=datetime.utcnow() + timedelta(days=90),
|
||||
status=EvidenceStatusEnum(parsed["evidence_status"]),
|
||||
# CI pipeline evidence → E3 observed (system-observed, hash-verified)
|
||||
confidence_level=EvidenceConfidenceEnum.E3,
|
||||
truth_status=EvidenceTruthStatusEnum.OBSERVED,
|
||||
may_be_used_as_evidence=True,
|
||||
)
|
||||
db.add(evidence)
|
||||
db.commit()
|
||||
@@ -639,3 +717,169 @@ async def get_ci_evidence_status(
|
||||
"total_evidence": len(evidence_list),
|
||||
"controls": result,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Evidence Review (Anti-Fake-Evidence)
|
||||
# ============================================================================
|
||||
|
||||
from pydantic import BaseModel as _BaseModel
|
||||
|
||||
class _EvidenceReviewRequest(_BaseModel):
|
||||
confidence_level: Optional[str] = None
|
||||
truth_status: Optional[str] = None
|
||||
reviewed_by: str
|
||||
|
||||
|
||||
@router.patch("/evidence/{evidence_id}/review", response_model=EvidenceResponse)
|
||||
async def review_evidence(
|
||||
evidence_id: str,
|
||||
review: _EvidenceReviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Review evidence: upgrade confidence level and/or change truth status.
|
||||
|
||||
For Four-Eyes evidence, the first reviewer sets first_reviewer and
|
||||
approval_status='first_approved'. A second (different) reviewer then
|
||||
sets second_reviewer and approval_status='approved'.
|
||||
"""
|
||||
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
|
||||
if not evidence:
|
||||
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
|
||||
|
||||
old_confidence = evidence.confidence_level.value if evidence.confidence_level else None
|
||||
old_truth = evidence.truth_status.value if evidence.truth_status else None
|
||||
|
||||
if review.confidence_level:
|
||||
try:
|
||||
evidence.confidence_level = EvidenceConfidenceEnum(review.confidence_level)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid confidence_level: {review.confidence_level}")
|
||||
|
||||
if review.truth_status:
|
||||
try:
|
||||
evidence.truth_status = EvidenceTruthStatusEnum(review.truth_status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid truth_status: {review.truth_status}")
|
||||
|
||||
# Four-Eyes branching
|
||||
if evidence.requires_four_eyes:
|
||||
status = evidence.approval_status or "none"
|
||||
if status in ("none", "pending_first"):
|
||||
evidence.first_reviewer = review.reviewed_by
|
||||
evidence.first_reviewed_at = datetime.utcnow()
|
||||
evidence.approval_status = "first_approved"
|
||||
elif status == "first_approved":
|
||||
if review.reviewed_by == evidence.first_reviewer:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Four-Eyes: second reviewer must be different from first reviewer",
|
||||
)
|
||||
evidence.second_reviewer = review.reviewed_by
|
||||
evidence.second_reviewed_at = datetime.utcnow()
|
||||
evidence.approval_status = "approved"
|
||||
elif status == "approved":
|
||||
raise HTTPException(status_code=400, detail="Evidence already approved")
|
||||
elif status == "rejected":
|
||||
raise HTTPException(status_code=400, detail="Evidence was rejected — create new evidence instead")
|
||||
|
||||
evidence.reviewed_by = review.reviewed_by
|
||||
evidence.reviewed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
# Audit trail
|
||||
new_confidence = evidence.confidence_level.value if evidence.confidence_level else None
|
||||
if old_confidence != new_confidence:
|
||||
log_audit_trail(
|
||||
db, "evidence", evidence_id, evidence.title, "review",
|
||||
performed_by=review.reviewed_by,
|
||||
field_changed="confidence_level",
|
||||
old_value=old_confidence,
|
||||
new_value=new_confidence,
|
||||
)
|
||||
new_truth = evidence.truth_status.value if evidence.truth_status else None
|
||||
if old_truth != new_truth:
|
||||
log_audit_trail(
|
||||
db, "evidence", evidence_id, evidence.title, "review",
|
||||
performed_by=review.reviewed_by,
|
||||
field_changed="truth_status",
|
||||
old_value=old_truth,
|
||||
new_value=new_truth,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
db.refresh(evidence)
|
||||
return _build_evidence_response(evidence)
|
||||
|
||||
|
||||
@router.patch("/evidence/{evidence_id}/reject", response_model=EvidenceResponse)
|
||||
async def reject_evidence(
|
||||
evidence_id: str,
|
||||
body: EvidenceRejectRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reject evidence (sets approval_status='rejected')."""
|
||||
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
|
||||
if not evidence:
|
||||
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
|
||||
|
||||
evidence.approval_status = "rejected"
|
||||
evidence.reviewed_by = body.reviewed_by
|
||||
evidence.reviewed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
log_audit_trail(
|
||||
db, "evidence", evidence_id, evidence.title, "reject",
|
||||
performed_by=body.reviewed_by,
|
||||
change_summary=body.rejection_reason or "Evidence rejected",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
db.refresh(evidence)
|
||||
return _build_evidence_response(evidence)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Audit Trail Query
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/audit-trail")
|
||||
async def get_audit_trail(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Query audit trail entries for an entity."""
|
||||
query = db.query(AuditTrailDB)
|
||||
if entity_type:
|
||||
query = query.filter(AuditTrailDB.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(AuditTrailDB.entity_id == entity_id)
|
||||
if action:
|
||||
query = query.filter(AuditTrailDB.action == action)
|
||||
|
||||
records = query.order_by(AuditTrailDB.performed_at.desc()).limit(limit).all()
|
||||
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"id": r.id,
|
||||
"entity_type": r.entity_type,
|
||||
"entity_id": r.entity_id,
|
||||
"entity_name": r.entity_name,
|
||||
"action": r.action,
|
||||
"field_changed": r.field_changed,
|
||||
"old_value": r.old_value,
|
||||
"new_value": r.new_value,
|
||||
"change_summary": r.change_summary,
|
||||
"performed_by": r.performed_by,
|
||||
"performed_at": r.performed_at.isoformat() if r.performed_at else None,
|
||||
"checksum": r.checksum,
|
||||
}
|
||||
for r in records
|
||||
],
|
||||
"total": len(records),
|
||||
}
|
||||
|
||||
@@ -73,39 +73,8 @@ def generate_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def create_signature(data: str) -> str:
|
||||
"""Create SHA-256 signature."""
|
||||
return hashlib.sha256(data.encode()).hexdigest()
|
||||
|
||||
|
||||
def log_audit_trail(
|
||||
db: Session,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
entity_name: str,
|
||||
action: str,
|
||||
performed_by: str,
|
||||
field_changed: str = None,
|
||||
old_value: str = None,
|
||||
new_value: str = None,
|
||||
change_summary: str = None
|
||||
):
|
||||
"""Log an entry to the audit trail."""
|
||||
trail = AuditTrailDB(
|
||||
id=generate_id(),
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
action=action,
|
||||
field_changed=field_changed,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
change_summary=change_summary,
|
||||
performed_by=performed_by,
|
||||
performed_at=datetime.utcnow(),
|
||||
checksum=create_signature(f"{entity_type}|{entity_id}|{action}|{performed_by}")
|
||||
)
|
||||
db.add(trail)
|
||||
# Shared audit trail utilities — canonical implementation in audit_trail_utils.py
|
||||
from .audit_trail_utils import log_audit_trail, create_signature # noqa: E402
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -92,6 +92,15 @@ VALID_DOCUMENT_TYPES = {
|
||||
"business_continuity_policy",
|
||||
"disaster_recovery_policy",
|
||||
"crisis_management_policy",
|
||||
# CRA Cybersecurity (Migration 056)
|
||||
"cybersecurity_policy",
|
||||
# DSFA template
|
||||
"dsfa",
|
||||
# Module document templates (Migration 073)
|
||||
"vvt_register",
|
||||
"tom_documentation",
|
||||
"loeschkonzept",
|
||||
"pflichtenregister",
|
||||
}
|
||||
VALID_STATUSES = {"published", "draft", "archived"}
|
||||
|
||||
|
||||
162
backend-compliance/compliance/api/llm_audit_routes.py
Normal file
162
backend-compliance/compliance/api/llm_audit_routes.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
FastAPI routes for LLM Generation Audit Trail.
|
||||
|
||||
Endpoints:
|
||||
- POST /llm-audit: Record an LLM generation event
|
||||
- GET /llm-audit: List audit records with filters
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid as uuid_module
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
from ..db.models import LLMGenerationAuditDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["compliance-llm-audit"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas
|
||||
# ============================================================================
|
||||
|
||||
class LLMAuditCreate(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: Optional[str] = None
|
||||
generation_mode: str
|
||||
truth_status: str = "generated"
|
||||
may_be_used_as_evidence: bool = False
|
||||
llm_model: Optional[str] = None
|
||||
llm_provider: Optional[str] = None
|
||||
prompt_hash: Optional[str] = None
|
||||
input_summary: Optional[str] = None
|
||||
output_summary: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
tenant_id: Optional[str] = None
|
||||
|
||||
|
||||
class LLMAuditResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: Optional[str] = None
|
||||
entity_type: str
|
||||
entity_id: Optional[str] = None
|
||||
generation_mode: str
|
||||
truth_status: str
|
||||
may_be_used_as_evidence: bool
|
||||
llm_model: Optional[str] = None
|
||||
llm_provider: Optional[str] = None
|
||||
prompt_hash: Optional[str] = None
|
||||
input_summary: Optional[str] = None
|
||||
output_summary: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Routes
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/llm-audit", response_model=LLMAuditResponse)
|
||||
async def create_llm_audit(
|
||||
data: LLMAuditCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Record an LLM generation event for audit trail."""
|
||||
from ..db.models import EvidenceTruthStatusEnum
|
||||
|
||||
# Validate truth_status
|
||||
try:
|
||||
truth_enum = EvidenceTruthStatusEnum(data.truth_status)
|
||||
except ValueError:
|
||||
truth_enum = EvidenceTruthStatusEnum.GENERATED
|
||||
|
||||
record = LLMGenerationAuditDB(
|
||||
id=str(uuid_module.uuid4()),
|
||||
tenant_id=data.tenant_id,
|
||||
entity_type=data.entity_type,
|
||||
entity_id=data.entity_id,
|
||||
generation_mode=data.generation_mode,
|
||||
truth_status=truth_enum,
|
||||
may_be_used_as_evidence=data.may_be_used_as_evidence,
|
||||
llm_model=data.llm_model,
|
||||
llm_provider=data.llm_provider,
|
||||
prompt_hash=data.prompt_hash,
|
||||
input_summary=data.input_summary[:500] if data.input_summary else None,
|
||||
output_summary=data.output_summary[:500] if data.output_summary else None,
|
||||
extra_metadata=data.metadata or {},
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return LLMAuditResponse(
|
||||
id=record.id,
|
||||
tenant_id=record.tenant_id,
|
||||
entity_type=record.entity_type,
|
||||
entity_id=record.entity_id,
|
||||
generation_mode=record.generation_mode,
|
||||
truth_status=record.truth_status.value if record.truth_status else "generated",
|
||||
may_be_used_as_evidence=record.may_be_used_as_evidence,
|
||||
llm_model=record.llm_model,
|
||||
llm_provider=record.llm_provider,
|
||||
prompt_hash=record.prompt_hash,
|
||||
input_summary=record.input_summary,
|
||||
output_summary=record.output_summary,
|
||||
metadata=record.extra_metadata,
|
||||
created_at=record.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/llm-audit")
|
||||
async def list_llm_audit(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List LLM generation audit records with optional filters."""
|
||||
query = db.query(LLMGenerationAuditDB)
|
||||
|
||||
if entity_type:
|
||||
query = query.filter(LLMGenerationAuditDB.entity_type == entity_type)
|
||||
if entity_id:
|
||||
query = query.filter(LLMGenerationAuditDB.entity_id == entity_id)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
records = query.order_by(LLMGenerationAuditDB.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
return {
|
||||
"records": [
|
||||
LLMAuditResponse(
|
||||
id=r.id,
|
||||
tenant_id=r.tenant_id,
|
||||
entity_type=r.entity_type,
|
||||
entity_id=r.entity_id,
|
||||
generation_mode=r.generation_mode,
|
||||
truth_status=r.truth_status.value if r.truth_status else "generated",
|
||||
may_be_used_as_evidence=r.may_be_used_as_evidence,
|
||||
llm_model=r.llm_model,
|
||||
llm_provider=r.llm_provider,
|
||||
prompt_hash=r.prompt_hash,
|
||||
input_summary=r.input_summary,
|
||||
output_summary=r.output_summary,
|
||||
metadata=r.extra_metadata,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in records
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
}
|
||||
@@ -56,6 +56,7 @@ class LoeschfristCreate(BaseModel):
|
||||
responsible_person: Optional[str] = None
|
||||
release_process: Optional[str] = None
|
||||
linked_vvt_activity_ids: Optional[List[Any]] = None
|
||||
linked_vendor_ids: Optional[List[Any]] = None
|
||||
status: str = "DRAFT"
|
||||
last_review_date: Optional[datetime] = None
|
||||
next_review_date: Optional[datetime] = None
|
||||
@@ -86,6 +87,7 @@ class LoeschfristUpdate(BaseModel):
|
||||
responsible_person: Optional[str] = None
|
||||
release_process: Optional[str] = None
|
||||
linked_vvt_activity_ids: Optional[List[Any]] = None
|
||||
linked_vendor_ids: Optional[List[Any]] = None
|
||||
status: Optional[str] = None
|
||||
last_review_date: Optional[datetime] = None
|
||||
next_review_date: Optional[datetime] = None
|
||||
@@ -100,7 +102,7 @@ class StatusUpdate(BaseModel):
|
||||
# JSONB fields that need CAST
|
||||
JSONB_FIELDS = {
|
||||
"affected_groups", "data_categories", "legal_holds",
|
||||
"storage_locations", "linked_vvt_activity_ids", "tags"
|
||||
"storage_locations", "linked_vvt_activity_ids", "linked_vendor_ids", "tags"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class ObligationCreate(BaseModel):
|
||||
priority: str = "medium"
|
||||
responsible: Optional[str] = None
|
||||
linked_systems: Optional[List[str]] = None
|
||||
linked_vendor_ids: Optional[List[str]] = None
|
||||
assessment_id: Optional[str] = None
|
||||
rule_code: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
@@ -57,6 +58,7 @@ class ObligationUpdate(BaseModel):
|
||||
priority: Optional[str] = None
|
||||
responsible: Optional[str] = None
|
||||
linked_systems: Optional[List[str]] = None
|
||||
linked_vendor_ids: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@@ -173,14 +175,15 @@ async def create_obligation(
|
||||
|
||||
import json
|
||||
linked_systems = json.dumps(payload.linked_systems or [])
|
||||
linked_vendor_ids = json.dumps(payload.linked_vendor_ids or [])
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_obligations
|
||||
(tenant_id, title, description, source, source_article, deadline,
|
||||
status, priority, responsible, linked_systems, assessment_id, rule_code, notes)
|
||||
status, priority, responsible, linked_systems, linked_vendor_ids, assessment_id, rule_code, notes)
|
||||
VALUES
|
||||
(:tenant_id, :title, :description, :source, :source_article, :deadline,
|
||||
:status, :priority, :responsible, CAST(:linked_systems AS jsonb), :assessment_id, :rule_code, :notes)
|
||||
:status, :priority, :responsible, CAST(:linked_systems AS jsonb), CAST(:linked_vendor_ids AS jsonb), :assessment_id, :rule_code, :notes)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
@@ -193,6 +196,7 @@ async def create_obligation(
|
||||
"priority": payload.priority,
|
||||
"responsible": payload.responsible,
|
||||
"linked_systems": linked_systems,
|
||||
"linked_vendor_ids": linked_vendor_ids,
|
||||
"assessment_id": payload.assessment_id,
|
||||
"rule_code": payload.rule_code,
|
||||
"notes": payload.notes,
|
||||
@@ -235,6 +239,9 @@ async def update_obligation(
|
||||
if field == "linked_systems":
|
||||
updates["linked_systems"] = json.dumps(value or [])
|
||||
set_clauses.append("linked_systems = CAST(:linked_systems AS jsonb)")
|
||||
elif field == "linked_vendor_ids":
|
||||
updates["linked_vendor_ids"] = json.dumps(value or [])
|
||||
set_clauses.append("linked_vendor_ids = CAST(:linked_vendor_ids AS jsonb)")
|
||||
else:
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
from .audit_trail_utils import log_audit_trail
|
||||
from ..db import (
|
||||
RegulationRepository,
|
||||
RequirementRepository,
|
||||
@@ -595,6 +596,7 @@ async def get_control(control_id: str, db: Session = Depends(get_db)):
|
||||
review_frequency_days=control.review_frequency_days,
|
||||
status=control.status.value if control.status else None,
|
||||
status_notes=control.status_notes,
|
||||
status_justification=control.status_justification,
|
||||
last_reviewed_at=control.last_reviewed_at,
|
||||
next_review_at=control.next_review_at,
|
||||
created_at=control.created_at,
|
||||
@@ -617,16 +619,52 @@ async def update_control(
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
# Convert status string to enum
|
||||
# Convert status string to enum and validate transition
|
||||
if "status" in update_data:
|
||||
try:
|
||||
update_data["status"] = ControlStatusEnum(update_data["status"])
|
||||
new_status_enum = ControlStatusEnum(update_data["status"])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
|
||||
|
||||
# Validate status transition (Anti-Fake-Evidence)
|
||||
from ..services.control_status_machine import validate_transition
|
||||
current_status = control.status.value if control.status else "planned"
|
||||
evidence_list = db.query(EvidenceDB).filter(EvidenceDB.control_id == control.id).all()
|
||||
allowed, violations = validate_transition(
|
||||
current_status=current_status,
|
||||
new_status=update_data["status"],
|
||||
evidence_list=evidence_list,
|
||||
status_justification=update_data.get("status_justification") or update_data.get("status_notes"),
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "Status transition not allowed",
|
||||
"current_status": current_status,
|
||||
"requested_status": update_data["status"],
|
||||
"violations": violations,
|
||||
}
|
||||
)
|
||||
|
||||
update_data["status"] = new_status_enum
|
||||
|
||||
updated = repo.update(control.id, **update_data)
|
||||
db.commit()
|
||||
|
||||
# Audit trail for status changes
|
||||
new_status = updated.status.value if updated.status else None
|
||||
if "status" in update.model_dump(exclude_unset=True) and current_status != new_status:
|
||||
log_audit_trail(
|
||||
db, "control", control.id, updated.control_id or updated.title,
|
||||
"status_change",
|
||||
performed_by=update.owner or "system",
|
||||
field_changed="status",
|
||||
old_value=current_status,
|
||||
new_value=new_status,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return ControlResponse(
|
||||
id=updated.id,
|
||||
control_id=updated.control_id,
|
||||
@@ -645,6 +683,7 @@ async def update_control(
|
||||
review_frequency_days=updated.review_frequency_days,
|
||||
status=updated.status.value if updated.status else None,
|
||||
status_notes=updated.status_notes,
|
||||
status_justification=updated.status_justification,
|
||||
last_reviewed_at=updated.last_reviewed_at,
|
||||
next_review_at=updated.next_review_at,
|
||||
created_at=updated.created_at,
|
||||
@@ -690,6 +729,7 @@ async def review_control(
|
||||
review_frequency_days=updated.review_frequency_days,
|
||||
status=updated.status.value if updated.status else None,
|
||||
status_notes=updated.status_notes,
|
||||
status_justification=updated.status_justification,
|
||||
last_reviewed_at=updated.last_reviewed_at,
|
||||
next_review_at=updated.next_review_at,
|
||||
created_at=updated.created_at,
|
||||
|
||||
@@ -43,6 +43,7 @@ class ControlStatus(str):
|
||||
FAIL = "fail"
|
||||
NOT_APPLICABLE = "n/a"
|
||||
PLANNED = "planned"
|
||||
IN_PROGRESS = "in_progress"
|
||||
|
||||
|
||||
class RiskLevel(str):
|
||||
@@ -209,12 +210,14 @@ class ControlUpdate(BaseModel):
|
||||
owner: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
status_notes: Optional[str] = None
|
||||
status_justification: Optional[str] = None
|
||||
|
||||
|
||||
class ControlResponse(ControlBase):
|
||||
id: str
|
||||
status: str
|
||||
status_notes: Optional[str] = None
|
||||
status_justification: Optional[str] = None
|
||||
last_reviewed_at: Optional[datetime] = None
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
@@ -291,7 +294,8 @@ class EvidenceBase(BaseModel):
|
||||
|
||||
|
||||
class EvidenceCreate(EvidenceBase):
|
||||
pass
|
||||
confidence_level: Optional[str] = None
|
||||
truth_status: Optional[str] = None
|
||||
|
||||
|
||||
class EvidenceResponse(EvidenceBase):
|
||||
@@ -304,6 +308,20 @@ class EvidenceResponse(EvidenceBase):
|
||||
uploaded_by: Optional[str] = None
|
||||
collected_at: datetime
|
||||
created_at: datetime
|
||||
# Anti-Fake-Evidence fields
|
||||
confidence_level: Optional[str] = None
|
||||
truth_status: Optional[str] = None
|
||||
generation_mode: Optional[str] = None
|
||||
may_be_used_as_evidence: Optional[bool] = None
|
||||
reviewed_by: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
# Anti-Fake-Evidence Phase 2: Four-Eyes
|
||||
approval_status: Optional[str] = None
|
||||
first_reviewer: Optional[str] = None
|
||||
first_reviewed_at: Optional[datetime] = None
|
||||
second_reviewer: Optional[str] = None
|
||||
second_reviewed_at: Optional[datetime] = None
|
||||
requires_four_eyes: Optional[bool] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -435,6 +453,25 @@ class AISystemListResponse(BaseModel):
|
||||
# Dashboard & Export Schemas
|
||||
# ============================================================================
|
||||
|
||||
class MultiDimensionalScore(BaseModel):
|
||||
"""Multi-dimensional compliance score (Anti-Fake-Evidence)."""
|
||||
requirement_coverage: float = 0.0 # % requirements with linked control
|
||||
evidence_strength: float = 0.0 # Weighted avg of evidence confidence (E0=0..E4=1)
|
||||
validation_quality: float = 0.0 # % evidence with truth_status >= validated_internal
|
||||
evidence_freshness: float = 0.0 # % evidence not expired + reviewed < 90 days
|
||||
control_effectiveness: float = 0.0 # Existing formula (pass + partial*0.5)
|
||||
overall_readiness: float = 0.0 # Weighted composite
|
||||
hard_blocks: List[str] = [] # Blocking issues preventing audit-readiness
|
||||
|
||||
|
||||
class StatusTransitionError(BaseModel):
|
||||
"""Error detail for forbidden control status transitions."""
|
||||
allowed: bool = False
|
||||
current_status: str
|
||||
requested_status: str
|
||||
violations: List[str] = []
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
compliance_score: float
|
||||
total_regulations: int
|
||||
@@ -447,6 +484,7 @@ class DashboardResponse(BaseModel):
|
||||
total_risks: int
|
||||
risks_by_level: Dict[str, int]
|
||||
recent_activity: List[Dict[str, Any]]
|
||||
multi_score: Optional[MultiDimensionalScore] = None
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
@@ -1755,6 +1793,20 @@ class VVTActivityCreate(BaseModel):
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
dsfa_id: Optional[str] = None
|
||||
# Library refs (optional, parallel to freetext)
|
||||
purpose_refs: Optional[List[str]] = None
|
||||
legal_basis_refs: Optional[List[str]] = None
|
||||
data_subject_refs: Optional[List[str]] = None
|
||||
data_category_refs: Optional[List[str]] = None
|
||||
recipient_refs: Optional[List[str]] = None
|
||||
retention_rule_ref: Optional[str] = None
|
||||
transfer_mechanism_refs: Optional[List[str]] = None
|
||||
tom_refs: Optional[List[str]] = None
|
||||
source_template_id: Optional[str] = None
|
||||
risk_score: Optional[int] = None
|
||||
linked_loeschfristen_ids: Optional[List[str]] = None
|
||||
linked_tom_measure_ids: Optional[List[str]] = None
|
||||
art30_completeness: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class VVTActivityUpdate(BaseModel):
|
||||
@@ -1783,6 +1835,20 @@ class VVTActivityUpdate(BaseModel):
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
dsfa_id: Optional[str] = None
|
||||
# Library refs
|
||||
purpose_refs: Optional[List[str]] = None
|
||||
legal_basis_refs: Optional[List[str]] = None
|
||||
data_subject_refs: Optional[List[str]] = None
|
||||
data_category_refs: Optional[List[str]] = None
|
||||
recipient_refs: Optional[List[str]] = None
|
||||
retention_rule_ref: Optional[str] = None
|
||||
transfer_mechanism_refs: Optional[List[str]] = None
|
||||
tom_refs: Optional[List[str]] = None
|
||||
source_template_id: Optional[str] = None
|
||||
risk_score: Optional[int] = None
|
||||
linked_loeschfristen_ids: Optional[List[str]] = None
|
||||
linked_tom_measure_ids: Optional[List[str]] = None
|
||||
art30_completeness: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class VVTActivityResponse(BaseModel):
|
||||
@@ -1813,6 +1879,20 @@ class VVTActivityResponse(BaseModel):
|
||||
next_review_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
dsfa_id: Optional[str] = None
|
||||
# Library refs
|
||||
purpose_refs: Optional[List[str]] = None
|
||||
legal_basis_refs: Optional[List[str]] = None
|
||||
data_subject_refs: Optional[List[str]] = None
|
||||
data_category_refs: Optional[List[str]] = None
|
||||
recipient_refs: Optional[List[str]] = None
|
||||
retention_rule_ref: Optional[str] = None
|
||||
transfer_mechanism_refs: Optional[List[str]] = None
|
||||
tom_refs: Optional[List[str]] = None
|
||||
source_template_id: Optional[str] = None
|
||||
risk_score: Optional[int] = None
|
||||
linked_loeschfristen_ids: Optional[List[str]] = None
|
||||
linked_tom_measure_ids: Optional[List[str]] = None
|
||||
art30_completeness: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -1897,3 +1977,111 @@ class TOMStatsResponse(BaseModel):
|
||||
implemented: int = 0
|
||||
partial: int = 0
|
||||
not_implemented: int = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Assertion Schemas (Anti-Fake-Evidence Phase 2)
|
||||
# ============================================================================
|
||||
|
||||
class AssertionCreate(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
sentence_text: str
|
||||
assertion_type: Optional[str] = "assertion"
|
||||
evidence_ids: Optional[List[str]] = []
|
||||
normative_tier: Optional[str] = None
|
||||
|
||||
|
||||
class AssertionUpdate(BaseModel):
|
||||
sentence_text: Optional[str] = None
|
||||
assertion_type: Optional[str] = None
|
||||
evidence_ids: Optional[List[str]] = None
|
||||
normative_tier: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
|
||||
|
||||
class AssertionResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: Optional[str] = None
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
sentence_text: str
|
||||
sentence_index: int = 0
|
||||
assertion_type: str = "assertion"
|
||||
evidence_ids: Optional[List[str]] = []
|
||||
confidence: float = 0.0
|
||||
normative_tier: Optional[str] = None
|
||||
verified_by: Optional[str] = None
|
||||
verified_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssertionListResponse(BaseModel):
|
||||
assertions: List[AssertionResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AssertionSummaryResponse(BaseModel):
|
||||
total_assertions: int = 0
|
||||
total_facts: int = 0
|
||||
total_rationale: int = 0
|
||||
unverified_count: int = 0
|
||||
|
||||
|
||||
class AssertionExtractRequest(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
text: str
|
||||
|
||||
|
||||
class EvidenceRejectRequest(BaseModel):
|
||||
reviewed_by: str
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Traceability Matrix (Anti-Fake-Evidence Phase 4a)
|
||||
# ============================================================================
|
||||
|
||||
class TraceabilityAssertion(BaseModel):
|
||||
"""Single assertion linked to an evidence item."""
|
||||
id: str
|
||||
sentence_text: str
|
||||
assertion_type: str = "assertion"
|
||||
confidence: float = 0.0
|
||||
verified: bool = False
|
||||
|
||||
class TraceabilityEvidence(BaseModel):
|
||||
"""Evidence item with nested assertions."""
|
||||
id: str
|
||||
title: str
|
||||
evidence_type: str
|
||||
confidence_level: str = "E1"
|
||||
status: str = "valid"
|
||||
assertions: List[TraceabilityAssertion] = []
|
||||
|
||||
class TraceabilityCoverage(BaseModel):
|
||||
"""Coverage flags for a single control."""
|
||||
has_evidence: bool = False
|
||||
has_assertions: bool = False
|
||||
all_assertions_verified: bool = False
|
||||
min_confidence_level: Optional[str] = None
|
||||
|
||||
class TraceabilityControl(BaseModel):
|
||||
"""Control with nested evidence and coverage info."""
|
||||
id: str
|
||||
control_id: str
|
||||
title: str
|
||||
status: str = "planned"
|
||||
domain: str = "unknown"
|
||||
evidence: List[TraceabilityEvidence] = []
|
||||
coverage: TraceabilityCoverage = TraceabilityCoverage()
|
||||
|
||||
class TraceabilityMatrixResponse(BaseModel):
|
||||
"""Full traceability matrix: Controls → Evidence → Assertions."""
|
||||
controls: List[TraceabilityControl]
|
||||
summary: Dict[str, int]
|
||||
|
||||
537
backend-compliance/compliance/api/tom_mapping_routes.py
Normal file
537
backend-compliance/compliance/api/tom_mapping_routes.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
TOM ↔ Canonical Control Mapping Routes.
|
||||
|
||||
Three-layer architecture:
|
||||
TOM Measures (~88, audit-level) → Mapping Bridge → Canonical Controls (10,000+)
|
||||
|
||||
Endpoints:
|
||||
POST /v1/tom-mappings/sync — Sync canonical controls for company profile
|
||||
GET /v1/tom-mappings — List all mappings for tenant/project
|
||||
GET /v1/tom-mappings/by-tom/{code} — Mappings for a specific TOM control
|
||||
GET /v1/tom-mappings/stats — Coverage statistics
|
||||
POST /v1/tom-mappings/manual — Manually add a mapping
|
||||
DELETE /v1/tom-mappings/{id} — Remove a mapping
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/tom-mappings", tags=["tom-control-mappings"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOM CATEGORY → CANONICAL CATEGORY MAPPING
|
||||
# =============================================================================
|
||||
|
||||
# Maps 13 TOM control categories to canonical_control_categories
|
||||
# Each TOM category maps to 1-3 canonical categories for broad coverage
|
||||
TOM_TO_CANONICAL_CATEGORIES: dict[str, list[str]] = {
|
||||
"ACCESS_CONTROL": ["authentication", "identity", "physical"],
|
||||
"ADMISSION_CONTROL": ["authentication", "identity", "system"],
|
||||
"ACCESS_AUTHORIZATION": ["authentication", "identity"],
|
||||
"TRANSFER_CONTROL": ["network", "data_protection", "encryption"],
|
||||
"INPUT_CONTROL": ["application", "data_protection"],
|
||||
"ORDER_CONTROL": ["supply_chain", "compliance"],
|
||||
"AVAILABILITY": ["continuity", "system"],
|
||||
"SEPARATION": ["network", "data_protection"],
|
||||
"ENCRYPTION": ["encryption"],
|
||||
"PSEUDONYMIZATION": ["data_protection", "encryption"],
|
||||
"RESILIENCE": ["continuity", "system"],
|
||||
"RECOVERY": ["continuity"],
|
||||
"REVIEW": ["compliance", "governance", "risk"],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REQUEST / RESPONSE MODELS
|
||||
# =============================================================================
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
"""Trigger a sync of canonical controls to TOM measures."""
|
||||
industry: Optional[str] = None
|
||||
company_size: Optional[str] = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
class ManualMappingRequest(BaseModel):
|
||||
"""Manually add a canonical control to a TOM measure."""
|
||||
tom_control_code: str
|
||||
tom_category: str
|
||||
canonical_control_id: str
|
||||
canonical_control_code: str
|
||||
canonical_category: Optional[str] = None
|
||||
relevance_score: float = 1.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str]) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-ID header required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
def _compute_profile_hash(industry: Optional[str], company_size: Optional[str]) -> str:
|
||||
"""Compute a hash from profile parameters for change detection."""
|
||||
data = json.dumps({"industry": industry, "company_size": company_size}, sort_keys=True)
|
||||
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _mapping_row_to_dict(r) -> dict[str, Any]:
|
||||
"""Convert a mapping row to API response dict."""
|
||||
return {
|
||||
"id": str(r.id),
|
||||
"tenant_id": str(r.tenant_id),
|
||||
"project_id": str(r.project_id) if r.project_id else None,
|
||||
"tom_control_code": r.tom_control_code,
|
||||
"tom_category": r.tom_category,
|
||||
"canonical_control_id": str(r.canonical_control_id),
|
||||
"canonical_control_code": r.canonical_control_code,
|
||||
"canonical_category": r.canonical_category,
|
||||
"mapping_type": r.mapping_type,
|
||||
"relevance_score": float(r.relevance_score) if r.relevance_score else 1.0,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SYNC ENDPOINT
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_mappings(
|
||||
body: SyncRequest,
|
||||
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Sync canonical controls to TOM measures based on company profile.
|
||||
|
||||
Algorithm:
|
||||
1. Compute profile hash → skip if unchanged (unless force=True)
|
||||
2. For each TOM category, find matching canonical controls by:
|
||||
- Category mapping (TOM category → canonical categories)
|
||||
- Industry filter (applicable_industries JSONB containment)
|
||||
- Company size filter (applicable_company_size JSONB containment)
|
||||
- Only approved + customer_visible controls
|
||||
3. Delete old auto-mappings, insert new ones
|
||||
4. Update sync state
|
||||
"""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
profile_hash = _compute_profile_hash(body.industry, body.company_size)
|
||||
|
||||
with SessionLocal() as db:
|
||||
# Check if sync is needed (profile unchanged)
|
||||
if not body.force:
|
||||
existing = db.execute(
|
||||
text("""
|
||||
SELECT profile_hash FROM tom_control_sync_state
|
||||
WHERE tenant_id = :tid AND (project_id = :pid OR (project_id IS NULL AND :pid IS NULL))
|
||||
"""),
|
||||
{"tid": tenant_id, "pid": project_id},
|
||||
).fetchone()
|
||||
if existing and existing.profile_hash == profile_hash:
|
||||
return {
|
||||
"status": "unchanged",
|
||||
"message": "Profile unchanged since last sync",
|
||||
"profile_hash": profile_hash,
|
||||
}
|
||||
|
||||
# Delete old auto-mappings for this tenant+project
|
||||
db.execute(
|
||||
text("""
|
||||
DELETE FROM tom_control_mappings
|
||||
WHERE tenant_id = :tid
|
||||
AND (project_id = :pid OR (project_id IS NULL AND :pid IS NULL))
|
||||
AND mapping_type = 'auto'
|
||||
"""),
|
||||
{"tid": tenant_id, "pid": project_id},
|
||||
)
|
||||
|
||||
total_mappings = 0
|
||||
canonical_ids_matched = set()
|
||||
tom_codes_covered = set()
|
||||
|
||||
# For each TOM category, find matching canonical controls
|
||||
for tom_category, canonical_categories in TOM_TO_CANONICAL_CATEGORIES.items():
|
||||
# Build JSONB containment query for categories
|
||||
cat_conditions = " OR ".join(
|
||||
f"category = :cat_{i}" for i in range(len(canonical_categories))
|
||||
)
|
||||
cat_params = {f"cat_{i}": c for i, c in enumerate(canonical_categories)}
|
||||
|
||||
# Build industry filter
|
||||
industry_filter = ""
|
||||
if body.industry:
|
||||
industry_filter = """
|
||||
AND (
|
||||
applicable_industries IS NULL
|
||||
OR applicable_industries @> '"all"'::jsonb
|
||||
OR applicable_industries @> (:industry)::jsonb
|
||||
)
|
||||
"""
|
||||
cat_params["industry"] = json.dumps([body.industry])
|
||||
|
||||
# Build company size filter
|
||||
size_filter = ""
|
||||
if body.company_size:
|
||||
size_filter = """
|
||||
AND (
|
||||
applicable_company_size IS NULL
|
||||
OR applicable_company_size @> '"all"'::jsonb
|
||||
OR applicable_company_size @> (:csize)::jsonb
|
||||
)
|
||||
"""
|
||||
cat_params["csize"] = json.dumps([body.company_size])
|
||||
|
||||
query = f"""
|
||||
SELECT id, control_id, category
|
||||
FROM canonical_controls
|
||||
WHERE ({cat_conditions})
|
||||
AND release_state = 'approved'
|
||||
AND customer_visible = true
|
||||
{industry_filter}
|
||||
{size_filter}
|
||||
ORDER BY control_id
|
||||
"""
|
||||
|
||||
rows = db.execute(text(query), cat_params).fetchall()
|
||||
|
||||
# Find TOM control codes in this category (query the frontend library
|
||||
# codes; we use the category prefix pattern from the loader)
|
||||
# TOM codes follow pattern: TOM-XX-NN where XX is category abbreviation
|
||||
# We insert one mapping per canonical control per TOM category
|
||||
for row in rows:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO tom_control_mappings (
|
||||
tenant_id, project_id, tom_control_code, tom_category,
|
||||
canonical_control_id, canonical_control_code, canonical_category,
|
||||
mapping_type, relevance_score
|
||||
) VALUES (
|
||||
:tid, :pid, :tom_cat, :tom_cat,
|
||||
:cc_id, :cc_code, :cc_category,
|
||||
'auto', 1.00
|
||||
)
|
||||
ON CONFLICT (tenant_id, project_id, tom_control_code, canonical_control_id)
|
||||
DO NOTHING
|
||||
"""),
|
||||
{
|
||||
"tid": tenant_id,
|
||||
"pid": project_id,
|
||||
"tom_cat": tom_category,
|
||||
"cc_id": str(row.id),
|
||||
"cc_code": row.control_id,
|
||||
"cc_category": row.category,
|
||||
},
|
||||
)
|
||||
total_mappings += 1
|
||||
canonical_ids_matched.add(str(row.id))
|
||||
tom_codes_covered.add(tom_category)
|
||||
|
||||
# Upsert sync state
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO tom_control_sync_state (
|
||||
tenant_id, project_id, profile_hash,
|
||||
total_mappings, canonical_controls_matched, tom_controls_covered,
|
||||
last_synced_at
|
||||
) VALUES (
|
||||
:tid, :pid, :hash,
|
||||
:total, :matched, :covered,
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (tenant_id, project_id)
|
||||
DO UPDATE SET
|
||||
profile_hash = :hash,
|
||||
total_mappings = :total,
|
||||
canonical_controls_matched = :matched,
|
||||
tom_controls_covered = :covered,
|
||||
last_synced_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"tid": tenant_id,
|
||||
"pid": project_id,
|
||||
"hash": profile_hash,
|
||||
"total": total_mappings,
|
||||
"matched": len(canonical_ids_matched),
|
||||
"covered": len(tom_codes_covered),
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "synced",
|
||||
"profile_hash": profile_hash,
|
||||
"total_mappings": total_mappings,
|
||||
"canonical_controls_matched": len(canonical_ids_matched),
|
||||
"tom_categories_covered": len(tom_codes_covered),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LIST MAPPINGS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
async def list_mappings(
|
||||
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
tom_category: Optional[str] = Query(None),
|
||||
mapping_type: Optional[str] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all TOM ↔ canonical control mappings for tenant/project."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
query = """
|
||||
SELECT m.*, cc.title as canonical_title, cc.severity as canonical_severity
|
||||
FROM tom_control_mappings m
|
||||
LEFT JOIN canonical_controls cc ON cc.id = m.canonical_control_id
|
||||
WHERE m.tenant_id = :tid
|
||||
AND (m.project_id = :pid OR (m.project_id IS NULL AND :pid IS NULL))
|
||||
"""
|
||||
params: dict[str, Any] = {"tid": tenant_id, "pid": project_id}
|
||||
|
||||
if tom_category:
|
||||
query += " AND m.tom_category = :tcat"
|
||||
params["tcat"] = tom_category
|
||||
if mapping_type:
|
||||
query += " AND m.mapping_type = :mtype"
|
||||
params["mtype"] = mapping_type
|
||||
|
||||
query += " ORDER BY m.tom_category, m.canonical_control_code"
|
||||
query += " LIMIT :lim OFFSET :off"
|
||||
params["lim"] = limit
|
||||
params["off"] = offset
|
||||
|
||||
count_query = """
|
||||
SELECT count(*) FROM tom_control_mappings
|
||||
WHERE tenant_id = :tid
|
||||
AND (project_id = :pid OR (project_id IS NULL AND :pid IS NULL))
|
||||
"""
|
||||
count_params: dict[str, Any] = {"tid": tenant_id, "pid": project_id}
|
||||
if tom_category:
|
||||
count_query += " AND tom_category = :tcat"
|
||||
count_params["tcat"] = tom_category
|
||||
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(text(query), params).fetchall()
|
||||
total = db.execute(text(count_query), count_params).scalar()
|
||||
|
||||
mappings = []
|
||||
for r in rows:
|
||||
d = _mapping_row_to_dict(r)
|
||||
d["canonical_title"] = getattr(r, "canonical_title", None)
|
||||
d["canonical_severity"] = getattr(r, "canonical_severity", None)
|
||||
mappings.append(d)
|
||||
|
||||
return {"mappings": mappings, "total": total}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAPPINGS BY TOM CONTROL
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/by-tom/{tom_code}")
|
||||
async def get_mappings_by_tom(
|
||||
tom_code: str,
|
||||
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""Get all canonical controls mapped to a specific TOM control code or category."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
text("""
|
||||
SELECT m.*, cc.title as canonical_title, cc.severity as canonical_severity,
|
||||
cc.objective as canonical_objective
|
||||
FROM tom_control_mappings m
|
||||
LEFT JOIN canonical_controls cc ON cc.id = m.canonical_control_id
|
||||
WHERE m.tenant_id = :tid
|
||||
AND (m.project_id = :pid OR (m.project_id IS NULL AND :pid IS NULL))
|
||||
AND (m.tom_control_code = :code OR m.tom_category = :code)
|
||||
ORDER BY m.canonical_control_code
|
||||
"""),
|
||||
{"tid": tenant_id, "pid": project_id, "code": tom_code},
|
||||
).fetchall()
|
||||
|
||||
mappings = []
|
||||
for r in rows:
|
||||
d = _mapping_row_to_dict(r)
|
||||
d["canonical_title"] = getattr(r, "canonical_title", None)
|
||||
d["canonical_severity"] = getattr(r, "canonical_severity", None)
|
||||
d["canonical_objective"] = getattr(r, "canonical_objective", None)
|
||||
mappings.append(d)
|
||||
|
||||
return {"tom_code": tom_code, "mappings": mappings, "total": len(mappings)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATS
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_mapping_stats(
|
||||
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""Coverage statistics for TOM ↔ canonical control mappings."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
with SessionLocal() as db:
|
||||
# Sync state
|
||||
sync_state = db.execute(
|
||||
text("""
|
||||
SELECT * FROM tom_control_sync_state
|
||||
WHERE tenant_id = :tid
|
||||
AND (project_id = :pid OR (project_id IS NULL AND :pid IS NULL))
|
||||
"""),
|
||||
{"tid": tenant_id, "pid": project_id},
|
||||
).fetchone()
|
||||
|
||||
# Per-category breakdown
|
||||
category_stats = db.execute(
|
||||
text("""
|
||||
SELECT tom_category,
|
||||
count(*) as total_mappings,
|
||||
count(DISTINCT canonical_control_id) as unique_controls,
|
||||
count(*) FILTER (WHERE mapping_type = 'auto') as auto_count,
|
||||
count(*) FILTER (WHERE mapping_type = 'manual') as manual_count
|
||||
FROM tom_control_mappings
|
||||
WHERE tenant_id = :tid
|
||||
AND (project_id = :pid OR (project_id IS NULL AND :pid IS NULL))
|
||||
GROUP BY tom_category
|
||||
ORDER BY tom_category
|
||||
"""),
|
||||
{"tid": tenant_id, "pid": project_id},
|
||||
).fetchall()
|
||||
|
||||
# Total canonical controls in DB (approved + visible)
|
||||
total_canonical = db.execute(
|
||||
text("""
|
||||
SELECT count(*) FROM canonical_controls
|
||||
WHERE release_state = 'approved' AND customer_visible = true
|
||||
""")
|
||||
).scalar()
|
||||
|
||||
return {
|
||||
"sync_state": {
|
||||
"profile_hash": sync_state.profile_hash if sync_state else None,
|
||||
"total_mappings": sync_state.total_mappings if sync_state else 0,
|
||||
"canonical_controls_matched": sync_state.canonical_controls_matched if sync_state else 0,
|
||||
"tom_controls_covered": sync_state.tom_controls_covered if sync_state else 0,
|
||||
"last_synced_at": sync_state.last_synced_at.isoformat() if sync_state and sync_state.last_synced_at else None,
|
||||
},
|
||||
"category_breakdown": [
|
||||
{
|
||||
"tom_category": r.tom_category,
|
||||
"total_mappings": r.total_mappings,
|
||||
"unique_controls": r.unique_controls,
|
||||
"auto_count": r.auto_count,
|
||||
"manual_count": r.manual_count,
|
||||
}
|
||||
for r in category_stats
|
||||
],
|
||||
"total_canonical_controls_available": total_canonical or 0,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MANUAL MAPPING
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/manual", status_code=201)
|
||||
async def add_manual_mapping(
|
||||
body: ManualMappingRequest,
|
||||
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
||||
project_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""Manually add a canonical control to a TOM measure."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
with SessionLocal() as db:
|
||||
# Verify canonical control exists
|
||||
cc = db.execute(
|
||||
text("SELECT id, control_id, category FROM canonical_controls WHERE id = CAST(:cid AS uuid)"),
|
||||
{"cid": body.canonical_control_id},
|
||||
).fetchone()
|
||||
if not cc:
|
||||
raise HTTPException(status_code=404, detail="Canonical control not found")
|
||||
|
||||
try:
|
||||
row = db.execute(
|
||||
text("""
|
||||
INSERT INTO tom_control_mappings (
|
||||
tenant_id, project_id, tom_control_code, tom_category,
|
||||
canonical_control_id, canonical_control_code, canonical_category,
|
||||
mapping_type, relevance_score
|
||||
) VALUES (
|
||||
:tid, :pid, :tom_code, :tom_cat,
|
||||
CAST(:cc_id AS uuid), :cc_code, :cc_category,
|
||||
'manual', :score
|
||||
)
|
||||
RETURNING *
|
||||
"""),
|
||||
{
|
||||
"tid": tenant_id,
|
||||
"pid": project_id,
|
||||
"tom_code": body.tom_control_code,
|
||||
"tom_cat": body.tom_category,
|
||||
"cc_id": body.canonical_control_id,
|
||||
"cc_code": body.canonical_control_code,
|
||||
"cc_category": body.canonical_category or cc.category,
|
||||
"score": body.relevance_score,
|
||||
},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
|
||||
raise HTTPException(status_code=409, detail="Mapping already exists")
|
||||
raise
|
||||
|
||||
return _mapping_row_to_dict(row)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DELETE MAPPING
|
||||
# =============================================================================
|
||||
|
||||
@router.delete("/{mapping_id}", status_code=204)
|
||||
async def delete_mapping(
|
||||
mapping_id: str,
|
||||
x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"),
|
||||
):
|
||||
"""Remove a mapping (manual or auto)."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
with SessionLocal() as db:
|
||||
result = db.execute(
|
||||
text("""
|
||||
DELETE FROM tom_control_mappings
|
||||
WHERE id = CAST(:mid AS uuid) AND tenant_id = :tid
|
||||
"""),
|
||||
{"mid": mapping_id, "tid": tenant_id},
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
427
backend-compliance/compliance/api/vvt_library_routes.py
Normal file
427
backend-compliance/compliance/api/vvt_library_routes.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
FastAPI routes for VVT Master Libraries + Process Templates.
|
||||
|
||||
Library endpoints (read-only, global):
|
||||
GET /vvt/libraries — Overview: all library types + counts
|
||||
GET /vvt/libraries/data-subjects — Data subjects (filter: typical_for)
|
||||
GET /vvt/libraries/data-categories — Hierarchical (filter: parent_id, is_art9, flat)
|
||||
GET /vvt/libraries/recipients — Recipients (filter: type)
|
||||
GET /vvt/libraries/legal-bases — Legal bases (filter: is_art9, type)
|
||||
GET /vvt/libraries/retention-rules — Retention rules
|
||||
GET /vvt/libraries/transfer-mechanisms — Transfer mechanisms
|
||||
GET /vvt/libraries/purposes — Purposes (filter: typical_for)
|
||||
GET /vvt/libraries/toms — TOMs (filter: category)
|
||||
|
||||
Template endpoints:
|
||||
GET /vvt/templates — List templates (filter: business_function, search)
|
||||
GET /vvt/templates/{id} — Single template with resolved labels
|
||||
POST /vvt/templates/{id}/instantiate — Create VVT activity from template
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
from ..db.vvt_library_models import (
|
||||
VVTLibDataSubjectDB,
|
||||
VVTLibDataCategoryDB,
|
||||
VVTLibRecipientDB,
|
||||
VVTLibLegalBasisDB,
|
||||
VVTLibRetentionRuleDB,
|
||||
VVTLibTransferMechanismDB,
|
||||
VVTLibPurposeDB,
|
||||
VVTLibTomDB,
|
||||
VVTProcessTemplateDB,
|
||||
)
|
||||
from ..db.vvt_models import VVTActivityDB, VVTAuditLogDB
|
||||
from .tenant_utils import get_tenant_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/vvt", tags=["compliance-vvt-libraries"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper: row → dict
|
||||
# ============================================================================
|
||||
|
||||
def _row_to_dict(row, extra_fields=None):
|
||||
"""Generic row → dict for library items."""
|
||||
d = {
|
||||
"id": row.id,
|
||||
"label_de": row.label_de,
|
||||
}
|
||||
if hasattr(row, 'description_de') and row.description_de:
|
||||
d["description_de"] = row.description_de
|
||||
if hasattr(row, 'sort_order'):
|
||||
d["sort_order"] = row.sort_order
|
||||
if extra_fields:
|
||||
for f in extra_fields:
|
||||
if hasattr(row, f):
|
||||
val = getattr(row, f)
|
||||
if val is not None:
|
||||
d[f] = val
|
||||
return d
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Library Overview
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries")
|
||||
async def get_libraries_overview(db: Session = Depends(get_db)):
|
||||
"""Overview of all library types with item counts."""
|
||||
return {
|
||||
"libraries": [
|
||||
{"type": "data-subjects", "count": db.query(VVTLibDataSubjectDB).count()},
|
||||
{"type": "data-categories", "count": db.query(VVTLibDataCategoryDB).count()},
|
||||
{"type": "recipients", "count": db.query(VVTLibRecipientDB).count()},
|
||||
{"type": "legal-bases", "count": db.query(VVTLibLegalBasisDB).count()},
|
||||
{"type": "retention-rules", "count": db.query(VVTLibRetentionRuleDB).count()},
|
||||
{"type": "transfer-mechanisms", "count": db.query(VVTLibTransferMechanismDB).count()},
|
||||
{"type": "purposes", "count": db.query(VVTLibPurposeDB).count()},
|
||||
{"type": "toms", "count": db.query(VVTLibTomDB).count()},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Subjects
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/data-subjects")
|
||||
async def list_data_subjects(
|
||||
typical_for: Optional[str] = Query(None, description="Filter by business function"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(VVTLibDataSubjectDB).order_by(VVTLibDataSubjectDB.sort_order)
|
||||
rows = query.all()
|
||||
items = [_row_to_dict(r, ["art9_relevant", "typical_for"]) for r in rows]
|
||||
if typical_for:
|
||||
items = [i for i in items if typical_for in (i.get("typical_for") or [])]
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Categories (hierarchical)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/data-categories")
|
||||
async def list_data_categories(
|
||||
flat: Optional[bool] = Query(False, description="Return flat list instead of tree"),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
is_art9: Optional[bool] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(VVTLibDataCategoryDB).order_by(VVTLibDataCategoryDB.sort_order)
|
||||
if parent_id is not None:
|
||||
query = query.filter(VVTLibDataCategoryDB.parent_id == parent_id)
|
||||
if is_art9 is not None:
|
||||
query = query.filter(VVTLibDataCategoryDB.is_art9 == is_art9)
|
||||
rows = query.all()
|
||||
|
||||
extra = ["parent_id", "is_art9", "is_art10", "risk_weight", "default_retention_rule", "default_legal_basis"]
|
||||
items = [_row_to_dict(r, extra) for r in rows]
|
||||
|
||||
if flat or parent_id is not None or is_art9 is not None:
|
||||
return items
|
||||
|
||||
# Build tree
|
||||
by_parent: dict = {}
|
||||
for item in items:
|
||||
pid = item.get("parent_id")
|
||||
by_parent.setdefault(pid, []).append(item)
|
||||
|
||||
tree = []
|
||||
for item in by_parent.get(None, []):
|
||||
children = by_parent.get(item["id"], [])
|
||||
if children:
|
||||
item["children"] = children
|
||||
tree.append(item)
|
||||
return tree
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Recipients
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/recipients")
|
||||
async def list_recipients(
|
||||
type: Optional[str] = Query(None, description="INTERNAL, PROCESSOR, CONTROLLER, AUTHORITY"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(VVTLibRecipientDB).order_by(VVTLibRecipientDB.sort_order)
|
||||
if type:
|
||||
query = query.filter(VVTLibRecipientDB.type == type)
|
||||
rows = query.all()
|
||||
return [_row_to_dict(r, ["type", "is_third_country", "country"]) for r in rows]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Legal Bases
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/legal-bases")
|
||||
async def list_legal_bases(
|
||||
is_art9: Optional[bool] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(VVTLibLegalBasisDB).order_by(VVTLibLegalBasisDB.sort_order)
|
||||
if is_art9 is not None:
|
||||
query = query.filter(VVTLibLegalBasisDB.is_art9 == is_art9)
|
||||
if type:
|
||||
query = query.filter(VVTLibLegalBasisDB.type == type)
|
||||
rows = query.all()
|
||||
return [_row_to_dict(r, ["article", "type", "is_art9", "typical_national_law"]) for r in rows]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Retention Rules
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/retention-rules")
|
||||
async def list_retention_rules(db: Session = Depends(get_db)):
|
||||
rows = db.query(VVTLibRetentionRuleDB).order_by(VVTLibRetentionRuleDB.sort_order).all()
|
||||
return [_row_to_dict(r, ["legal_basis", "duration", "duration_unit", "start_event", "deletion_procedure"]) for r in rows]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Transfer Mechanisms
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/transfer-mechanisms")
|
||||
async def list_transfer_mechanisms(db: Session = Depends(get_db)):
|
||||
rows = db.query(VVTLibTransferMechanismDB).order_by(VVTLibTransferMechanismDB.sort_order).all()
|
||||
return [_row_to_dict(r, ["article", "requires_tia"]) for r in rows]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Purposes
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/purposes")
|
||||
async def list_purposes(
|
||||
typical_for: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
rows = db.query(VVTLibPurposeDB).order_by(VVTLibPurposeDB.sort_order).all()
|
||||
items = [_row_to_dict(r, ["typical_legal_basis", "typical_for"]) for r in rows]
|
||||
if typical_for:
|
||||
items = [i for i in items if typical_for in (i.get("typical_for") or [])]
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TOMs
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/libraries/toms")
|
||||
async def list_toms(
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(VVTLibTomDB).order_by(VVTLibTomDB.sort_order)
|
||||
if category:
|
||||
query = query.filter(VVTLibTomDB.category == category)
|
||||
rows = query.all()
|
||||
return [_row_to_dict(r, ["category", "art32_reference"]) for r in rows]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Process Templates
|
||||
# ============================================================================
|
||||
|
||||
def _template_to_dict(t: VVTProcessTemplateDB) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"business_function": t.business_function,
|
||||
"purpose_refs": t.purpose_refs or [],
|
||||
"legal_basis_refs": t.legal_basis_refs or [],
|
||||
"data_subject_refs": t.data_subject_refs or [],
|
||||
"data_category_refs": t.data_category_refs or [],
|
||||
"recipient_refs": t.recipient_refs or [],
|
||||
"tom_refs": t.tom_refs or [],
|
||||
"transfer_mechanism_refs": t.transfer_mechanism_refs or [],
|
||||
"retention_rule_ref": t.retention_rule_ref,
|
||||
"typical_systems": t.typical_systems or [],
|
||||
"protection_level": t.protection_level or "MEDIUM",
|
||||
"dpia_required": t.dpia_required or False,
|
||||
"risk_score": t.risk_score,
|
||||
"tags": t.tags or [],
|
||||
"is_system": t.is_system,
|
||||
"sort_order": t.sort_order,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_labels(template_dict: dict, db: Session) -> dict:
|
||||
"""Resolve library IDs to labels within the template dict."""
|
||||
resolvers = {
|
||||
"purpose_refs": (VVTLibPurposeDB, "purpose_labels"),
|
||||
"legal_basis_refs": (VVTLibLegalBasisDB, "legal_basis_labels"),
|
||||
"data_subject_refs": (VVTLibDataSubjectDB, "data_subject_labels"),
|
||||
"data_category_refs": (VVTLibDataCategoryDB, "data_category_labels"),
|
||||
"recipient_refs": (VVTLibRecipientDB, "recipient_labels"),
|
||||
"tom_refs": (VVTLibTomDB, "tom_labels"),
|
||||
"transfer_mechanism_refs": (VVTLibTransferMechanismDB, "transfer_mechanism_labels"),
|
||||
}
|
||||
for refs_key, (model, labels_key) in resolvers.items():
|
||||
ids = template_dict.get(refs_key) or []
|
||||
if ids:
|
||||
rows = db.query(model).filter(model.id.in_(ids)).all()
|
||||
label_map = {r.id: r.label_de for r in rows}
|
||||
template_dict[labels_key] = {rid: label_map.get(rid, rid) for rid in ids}
|
||||
|
||||
# Resolve single retention rule
|
||||
rr = template_dict.get("retention_rule_ref")
|
||||
if rr:
|
||||
row = db.query(VVTLibRetentionRuleDB).filter(VVTLibRetentionRuleDB.id == rr).first()
|
||||
if row:
|
||||
template_dict["retention_rule_label"] = row.label_de
|
||||
|
||||
return template_dict
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
business_function: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List process templates (system + tenant)."""
|
||||
query = db.query(VVTProcessTemplateDB).order_by(VVTProcessTemplateDB.sort_order)
|
||||
if business_function:
|
||||
query = query.filter(VVTProcessTemplateDB.business_function == business_function)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(VVTProcessTemplateDB.name.ilike(term)) |
|
||||
(VVTProcessTemplateDB.description.ilike(term))
|
||||
)
|
||||
templates = query.all()
|
||||
return [_template_to_dict(t) for t in templates]
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a single template with resolved library labels."""
|
||||
t = db.query(VVTProcessTemplateDB).filter(VVTProcessTemplateDB.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail=f"Template '{template_id}' not found")
|
||||
result = _template_to_dict(t)
|
||||
return _resolve_labels(result, db)
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/instantiate", status_code=201)
|
||||
async def instantiate_template(
|
||||
template_id: str,
|
||||
http_request: Request,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new VVT activity from a process template."""
|
||||
t = db.query(VVTProcessTemplateDB).filter(VVTProcessTemplateDB.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail=f"Template '{template_id}' not found")
|
||||
|
||||
# Generate unique VVT-ID
|
||||
count = db.query(VVTActivityDB).filter(VVTActivityDB.tenant_id == tid).count()
|
||||
vvt_id = f"VVT-{count + 1:04d}"
|
||||
|
||||
# Resolve library IDs to freetext labels for backward-compat fields
|
||||
purpose_labels = _resolve_ids(db, VVTLibPurposeDB, t.purpose_refs or [])
|
||||
legal_labels = _resolve_ids(db, VVTLibLegalBasisDB, t.legal_basis_refs or [])
|
||||
subject_labels = _resolve_ids(db, VVTLibDataSubjectDB, t.data_subject_refs or [])
|
||||
category_labels = _resolve_ids(db, VVTLibDataCategoryDB, t.data_category_refs or [])
|
||||
recipient_labels = _resolve_ids(db, VVTLibRecipientDB, t.recipient_refs or [])
|
||||
|
||||
# Resolve retention rule
|
||||
retention_period = {}
|
||||
if t.retention_rule_ref:
|
||||
rr = db.query(VVTLibRetentionRuleDB).filter(VVTLibRetentionRuleDB.id == t.retention_rule_ref).first()
|
||||
if rr:
|
||||
retention_period = {
|
||||
"description": rr.label_de,
|
||||
"legalBasis": rr.legal_basis or "",
|
||||
"deletionProcedure": rr.deletion_procedure or "",
|
||||
"duration": rr.duration,
|
||||
"durationUnit": rr.duration_unit,
|
||||
}
|
||||
|
||||
# Build structured TOMs from tom_refs
|
||||
structured_toms = {"accessControl": [], "confidentiality": [], "integrity": [], "availability": [], "separation": []}
|
||||
if t.tom_refs:
|
||||
tom_rows = db.query(VVTLibTomDB).filter(VVTLibTomDB.id.in_(t.tom_refs)).all()
|
||||
for tr in tom_rows:
|
||||
cat = tr.category
|
||||
if cat in structured_toms:
|
||||
structured_toms[cat].append(tr.label_de)
|
||||
|
||||
act = VVTActivityDB(
|
||||
tenant_id=tid,
|
||||
vvt_id=vvt_id,
|
||||
name=t.name,
|
||||
description=t.description or "",
|
||||
purposes=purpose_labels,
|
||||
legal_bases=[{"type": lid, "description": lbl} for lid, lbl in zip(t.legal_basis_refs or [], legal_labels)],
|
||||
data_subject_categories=subject_labels,
|
||||
personal_data_categories=category_labels,
|
||||
recipient_categories=[{"type": "unknown", "name": lbl} for lbl in recipient_labels],
|
||||
retention_period=retention_period,
|
||||
business_function=t.business_function,
|
||||
systems=[{"systemId": s, "name": s} for s in (t.typical_systems or [])],
|
||||
protection_level=t.protection_level or "MEDIUM",
|
||||
dpia_required=t.dpia_required or False,
|
||||
structured_toms=structured_toms,
|
||||
status="DRAFT",
|
||||
created_by=http_request.headers.get("X-User-ID", "system"),
|
||||
# Library refs
|
||||
purpose_refs=t.purpose_refs,
|
||||
legal_basis_refs=t.legal_basis_refs,
|
||||
data_subject_refs=t.data_subject_refs,
|
||||
data_category_refs=t.data_category_refs,
|
||||
recipient_refs=t.recipient_refs,
|
||||
retention_rule_ref=t.retention_rule_ref,
|
||||
transfer_mechanism_refs=t.transfer_mechanism_refs,
|
||||
tom_refs=t.tom_refs,
|
||||
source_template_id=t.id,
|
||||
risk_score=t.risk_score,
|
||||
)
|
||||
db.add(act)
|
||||
db.flush()
|
||||
|
||||
# Audit log
|
||||
audit = VVTAuditLogDB(
|
||||
tenant_id=tid,
|
||||
action="CREATE",
|
||||
entity_type="activity",
|
||||
entity_id=act.id,
|
||||
changed_by=http_request.headers.get("X-User-ID", "system"),
|
||||
new_values={"vvt_id": vvt_id, "source_template_id": t.id, "name": t.name},
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
db.refresh(act)
|
||||
|
||||
# Return full response
|
||||
from .vvt_routes import _activity_to_response
|
||||
return _activity_to_response(act)
|
||||
|
||||
|
||||
def _resolve_ids(db: Session, model, ids: list) -> list:
|
||||
"""Resolve list of library IDs to list of label_de strings."""
|
||||
if not ids:
|
||||
return []
|
||||
rows = db.query(model).filter(model.id.in_(ids)).all()
|
||||
label_map = {r.id: r.label_de for r in rows}
|
||||
return [label_map.get(i, i) for i in ids]
|
||||
@@ -174,6 +174,20 @@ def _activity_to_response(act: VVTActivityDB) -> VVTActivityResponse:
|
||||
next_review_at=act.next_review_at,
|
||||
created_by=act.created_by,
|
||||
dsfa_id=str(act.dsfa_id) if act.dsfa_id else None,
|
||||
# Library refs
|
||||
purpose_refs=act.purpose_refs,
|
||||
legal_basis_refs=act.legal_basis_refs,
|
||||
data_subject_refs=act.data_subject_refs,
|
||||
data_category_refs=act.data_category_refs,
|
||||
recipient_refs=act.recipient_refs,
|
||||
retention_rule_ref=act.retention_rule_ref,
|
||||
transfer_mechanism_refs=act.transfer_mechanism_refs,
|
||||
tom_refs=act.tom_refs,
|
||||
source_template_id=act.source_template_id,
|
||||
risk_score=act.risk_score,
|
||||
linked_loeschfristen_ids=act.linked_loeschfristen_ids,
|
||||
linked_tom_measure_ids=act.linked_tom_measure_ids,
|
||||
art30_completeness=act.art30_completeness,
|
||||
created_at=act.created_at,
|
||||
updated_at=act.updated_at,
|
||||
)
|
||||
@@ -336,6 +350,107 @@ async def delete_activity(
|
||||
return {"success": True, "message": f"Activity {activity_id} deleted"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Art. 30 Completeness Check
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/activities/{activity_id}/completeness")
|
||||
async def get_activity_completeness(
|
||||
activity_id: str,
|
||||
tid: str = Depends(get_tenant_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Calculate Art. 30 completeness score for a VVT activity."""
|
||||
act = db.query(VVTActivityDB).filter(
|
||||
VVTActivityDB.id == activity_id,
|
||||
VVTActivityDB.tenant_id == tid,
|
||||
).first()
|
||||
if not act:
|
||||
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
|
||||
return _calculate_completeness(act)
|
||||
|
||||
|
||||
def _calculate_completeness(act: VVTActivityDB) -> dict:
|
||||
"""Calculate Art. 30 completeness — required fields per DSGVO Art. 30 Abs. 1."""
|
||||
missing = []
|
||||
warnings = []
|
||||
total_checks = 10
|
||||
passed = 0
|
||||
|
||||
# 1. Name/Zweck
|
||||
if act.name:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("name")
|
||||
|
||||
# 2. Verarbeitungszwecke
|
||||
has_purposes = bool(act.purposes) or bool(act.purpose_refs)
|
||||
if has_purposes:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("purposes")
|
||||
|
||||
# 3. Rechtsgrundlage
|
||||
has_legal = bool(act.legal_bases) or bool(act.legal_basis_refs)
|
||||
if has_legal:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("legal_bases")
|
||||
|
||||
# 4. Betroffenenkategorien
|
||||
has_subjects = bool(act.data_subject_categories) or bool(act.data_subject_refs)
|
||||
if has_subjects:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("data_subjects")
|
||||
|
||||
# 5. Datenkategorien
|
||||
has_categories = bool(act.personal_data_categories) or bool(act.data_category_refs)
|
||||
if has_categories:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("data_categories")
|
||||
|
||||
# 6. Empfaenger
|
||||
has_recipients = bool(act.recipient_categories) or bool(act.recipient_refs)
|
||||
if has_recipients:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("recipients")
|
||||
|
||||
# 7. Drittland-Uebermittlung (checked but not strictly required)
|
||||
passed += 1 # always passes — no transfer is valid state
|
||||
|
||||
# 8. Loeschfristen
|
||||
has_retention = bool(act.retention_period and act.retention_period.get('description')) or bool(act.retention_rule_ref)
|
||||
if has_retention:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("retention_period")
|
||||
|
||||
# 9. TOM-Beschreibung
|
||||
has_tom = bool(act.tom_description) or bool(act.tom_refs) or bool(act.structured_toms)
|
||||
if has_tom:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("tom_description")
|
||||
|
||||
# 10. Verantwortlicher
|
||||
if act.responsible:
|
||||
passed += 1
|
||||
else:
|
||||
missing.append("responsible")
|
||||
|
||||
# Warnings
|
||||
if act.dpia_required and not act.dsfa_id:
|
||||
warnings.append("dpia_required_but_no_dsfa_linked")
|
||||
if act.third_country_transfers and not act.transfer_mechanism_refs:
|
||||
warnings.append("third_country_transfer_without_mechanism")
|
||||
|
||||
score = int((passed / total_checks) * 100)
|
||||
return {"score": score, "missing": missing, "warnings": warnings, "passed": passed, "total": total_checks}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Audit Log
|
||||
# ============================================================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user