feat: Anti-Fake-Evidence System (Phase 1-4b)
Implement full evidence integrity pipeline to prevent compliance theater: - Confidence levels (E0-E4), truth status tracking, assertion engine - Four-Eyes approval workflow, audit trail, reject endpoint - Evidence distribution dashboard, LLM audit routes - Traceability matrix (backend endpoint + Compliance Hub UI tab) - Anti-fake badges, control status machine, normative patterns - 2 migrations, 4 test suites, MkDocs documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -61,6 +61,8 @@ _ROUTER_MODULES = [
|
||||
"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)
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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):
|
||||
@@ -1939,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]
|
||||
|
||||
@@ -8,12 +8,16 @@ from .models import (
|
||||
EvidenceDB,
|
||||
RiskDB,
|
||||
AuditExportDB,
|
||||
LLMGenerationAuditDB,
|
||||
AssertionDB,
|
||||
RegulationTypeEnum,
|
||||
ControlTypeEnum,
|
||||
ControlDomainEnum,
|
||||
RiskLevelEnum,
|
||||
EvidenceStatusEnum,
|
||||
ControlStatusEnum,
|
||||
EvidenceConfidenceEnum,
|
||||
EvidenceTruthStatusEnum,
|
||||
)
|
||||
from .repository import (
|
||||
RegulationRepository,
|
||||
@@ -33,6 +37,8 @@ __all__ = [
|
||||
"EvidenceDB",
|
||||
"RiskDB",
|
||||
"AuditExportDB",
|
||||
"LLMGenerationAuditDB",
|
||||
"AssertionDB",
|
||||
# Enums
|
||||
"RegulationTypeEnum",
|
||||
"ControlTypeEnum",
|
||||
@@ -40,6 +46,8 @@ __all__ = [
|
||||
"RiskLevelEnum",
|
||||
"EvidenceStatusEnum",
|
||||
"ControlStatusEnum",
|
||||
"EvidenceConfidenceEnum",
|
||||
"EvidenceTruthStatusEnum",
|
||||
# Repositories
|
||||
"RegulationRepository",
|
||||
"RequirementRepository",
|
||||
|
||||
@@ -65,6 +65,7 @@ class ControlStatusEnum(str, enum.Enum):
|
||||
FAIL = "fail" # Not passing
|
||||
NOT_APPLICABLE = "n/a" # Not applicable
|
||||
PLANNED = "planned" # Planned for implementation
|
||||
IN_PROGRESS = "in_progress" # Implementation in progress
|
||||
|
||||
|
||||
class RiskLevelEnum(str, enum.Enum):
|
||||
@@ -83,6 +84,26 @@ class EvidenceStatusEnum(str, enum.Enum):
|
||||
FAILED = "failed" # Failed validation
|
||||
|
||||
|
||||
class EvidenceConfidenceEnum(str, enum.Enum):
|
||||
"""Confidence level of evidence (Anti-Fake-Evidence)."""
|
||||
E0 = "E0" # Generated / no real evidence (LLM output, placeholder)
|
||||
E1 = "E1" # Uploaded but unreviewed (manual upload, no hash, no reviewer)
|
||||
E2 = "E2" # Reviewed internally (human reviewed, hash verified)
|
||||
E3 = "E3" # Observed by system (CI/CD pipeline, API with hash)
|
||||
E4 = "E4" # Validated by external auditor
|
||||
|
||||
|
||||
class EvidenceTruthStatusEnum(str, enum.Enum):
|
||||
"""Truth status lifecycle for evidence (Anti-Fake-Evidence)."""
|
||||
GENERATED = "generated"
|
||||
UPLOADED = "uploaded"
|
||||
OBSERVED = "observed"
|
||||
VALIDATED_INTERNAL = "validated_internal"
|
||||
REJECTED = "rejected"
|
||||
PROVIDED_TO_AUDITOR = "provided_to_auditor"
|
||||
ACCEPTED_BY_AUDITOR = "accepted_by_auditor"
|
||||
|
||||
|
||||
class ExportStatusEnum(str, enum.Enum):
|
||||
"""Status of audit export."""
|
||||
PENDING = "pending"
|
||||
@@ -239,6 +260,7 @@ class ControlDB(Base):
|
||||
# Status
|
||||
status = Column(Enum(ControlStatusEnum), default=ControlStatusEnum.PLANNED)
|
||||
status_notes = Column(Text)
|
||||
status_justification = Column(Text) # Required for n/a transitions
|
||||
|
||||
# Ownership & Review
|
||||
owner = Column(String(100)) # Responsible person/team
|
||||
@@ -321,6 +343,22 @@ class EvidenceDB(Base):
|
||||
ci_job_id = Column(String(100)) # CI/CD job reference
|
||||
uploaded_by = Column(String(100)) # User who uploaded
|
||||
|
||||
# Anti-Fake-Evidence: Confidence & Truth tracking
|
||||
confidence_level = Column(Enum(EvidenceConfidenceEnum), default=EvidenceConfidenceEnum.E1)
|
||||
truth_status = Column(Enum(EvidenceTruthStatusEnum), default=EvidenceTruthStatusEnum.UPLOADED)
|
||||
generation_mode = Column(String(100)) # e.g. "draft_assistance", "auto_generation"
|
||||
may_be_used_as_evidence = Column(Boolean, default=True)
|
||||
reviewed_by = Column(String(200))
|
||||
reviewed_at = Column(DateTime)
|
||||
|
||||
# Anti-Fake-Evidence Phase 2: Four-Eyes review
|
||||
approval_status = Column(String(30), default="none")
|
||||
first_reviewer = Column(String(200))
|
||||
first_reviewed_at = Column(DateTime)
|
||||
second_reviewer = Column(String(200))
|
||||
second_reviewed_at = Column(DateTime)
|
||||
requires_four_eyes = Column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
collected_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -332,6 +370,7 @@ class EvidenceDB(Base):
|
||||
__table_args__ = (
|
||||
Index('ix_evidence_control_type', 'control_id', 'evidence_type'),
|
||||
Index('ix_evidence_status', 'status'),
|
||||
Index('ix_evidence_approval_status', 'approval_status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -1464,3 +1503,77 @@ class ISMSReadinessCheckDB(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ISMSReadiness {self.check_date}: {self.overall_status}>"
|
||||
|
||||
|
||||
class LLMGenerationAuditDB(Base):
|
||||
"""
|
||||
Audit trail for LLM-generated content.
|
||||
|
||||
Every piece of content generated by an LLM is recorded here with its
|
||||
truth_status and may_be_used_as_evidence flag, ensuring transparency
|
||||
about what is real evidence vs. generated assistance.
|
||||
"""
|
||||
__tablename__ = 'compliance_llm_generation_audit'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id = Column(String(36), index=True)
|
||||
|
||||
entity_type = Column(String(50), nullable=False) # 'evidence', 'control', 'document'
|
||||
entity_id = Column(String(36)) # FK to generated entity
|
||||
generation_mode = Column(String(100), nullable=False) # 'draft_assistance', 'auto_generation'
|
||||
truth_status = Column(Enum(EvidenceTruthStatusEnum), nullable=False, default=EvidenceTruthStatusEnum.GENERATED)
|
||||
may_be_used_as_evidence = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
llm_model = Column(String(100))
|
||||
llm_provider = Column(String(50)) # 'ollama', 'anthropic'
|
||||
prompt_hash = Column(String(64)) # SHA-256 of prompt
|
||||
input_summary = Column(Text)
|
||||
output_summary = Column(Text)
|
||||
extra_metadata = Column("metadata", JSON, default=dict)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_llm_audit_entity', 'entity_type', 'entity_id'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LLMGenerationAudit {self.entity_type}:{self.entity_id} mode={self.generation_mode}>"
|
||||
|
||||
|
||||
class AssertionDB(Base):
|
||||
"""
|
||||
Assertion tracking — separates claims from verified facts.
|
||||
|
||||
Each sentence from a control/evidence/document is stored here with its
|
||||
classification (assertion vs. fact vs. rationale) and optional evidence linkage.
|
||||
"""
|
||||
__tablename__ = 'compliance_assertions'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id = Column(String(36), index=True)
|
||||
|
||||
entity_type = Column(String(50), nullable=False) # 'control', 'evidence', 'document', 'obligation'
|
||||
entity_id = Column(String(36), nullable=False)
|
||||
sentence_text = Column(Text, nullable=False)
|
||||
sentence_index = Column(Integer, nullable=False, default=0)
|
||||
|
||||
assertion_type = Column(String(20), nullable=False, default='assertion') # 'assertion' | 'fact' | 'rationale'
|
||||
evidence_ids = Column(JSON, default=list)
|
||||
confidence = Column(Float, default=0.0)
|
||||
normative_tier = Column(String(20)) # 'pflicht' | 'empfehlung' | 'kann'
|
||||
|
||||
verified_by = Column(String(200))
|
||||
verified_at = Column(DateTime)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_assertion_entity', 'entity_type', 'entity_id'),
|
||||
Index('ix_assertion_type', 'assertion_type'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Assertion {self.assertion_type}: {self.sentence_text[:50]}>"
|
||||
|
||||
@@ -487,6 +487,137 @@ class ControlRepository:
|
||||
"compliance_score": round(score, 1),
|
||||
}
|
||||
|
||||
def get_multi_dimensional_score(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate multi-dimensional compliance score (Anti-Fake-Evidence).
|
||||
|
||||
Returns 6 dimensions + hard_blocks + overall_readiness.
|
||||
"""
|
||||
from .models import (
|
||||
EvidenceDB, RequirementDB, ControlMappingDB,
|
||||
EvidenceConfidenceEnum, EvidenceTruthStatusEnum,
|
||||
)
|
||||
|
||||
# Weight map for confidence levels
|
||||
conf_weights = {"E0": 0.0, "E1": 0.25, "E2": 0.5, "E3": 0.75, "E4": 1.0}
|
||||
validated_statuses = {"validated_internal", "accepted_by_auditor", "provided_to_auditor"}
|
||||
|
||||
controls = self.get_all()
|
||||
total_controls = len(controls)
|
||||
|
||||
if total_controls == 0:
|
||||
return {
|
||||
"requirement_coverage": 0.0,
|
||||
"evidence_strength": 0.0,
|
||||
"validation_quality": 0.0,
|
||||
"evidence_freshness": 0.0,
|
||||
"control_effectiveness": 0.0,
|
||||
"overall_readiness": 0.0,
|
||||
"hard_blocks": ["Keine Controls vorhanden"],
|
||||
}
|
||||
|
||||
# 1. requirement_coverage: % requirements linked to at least one control
|
||||
total_reqs = self.db.query(func.count(RequirementDB.id)).scalar() or 0
|
||||
linked_reqs = (
|
||||
self.db.query(func.count(func.distinct(ControlMappingDB.requirement_id)))
|
||||
.scalar() or 0
|
||||
)
|
||||
requirement_coverage = (linked_reqs / total_reqs * 100) if total_reqs > 0 else 0.0
|
||||
|
||||
# 2. evidence_strength: weighted average of evidence confidence
|
||||
all_evidence = self.db.query(EvidenceDB).all()
|
||||
if all_evidence:
|
||||
total_weight = 0.0
|
||||
for e in all_evidence:
|
||||
conf_val = e.confidence_level.value if e.confidence_level else "E1"
|
||||
total_weight += conf_weights.get(conf_val, 0.25)
|
||||
evidence_strength = (total_weight / len(all_evidence)) * 100
|
||||
else:
|
||||
evidence_strength = 0.0
|
||||
|
||||
# 3. validation_quality: % evidence with truth_status >= validated_internal
|
||||
if all_evidence:
|
||||
validated_count = sum(
|
||||
1 for e in all_evidence
|
||||
if (e.truth_status.value if e.truth_status else "uploaded") in validated_statuses
|
||||
)
|
||||
validation_quality = (validated_count / len(all_evidence)) * 100
|
||||
else:
|
||||
validation_quality = 0.0
|
||||
|
||||
# 4. evidence_freshness: % evidence not expired and reviewed < 90 days
|
||||
now = datetime.now()
|
||||
if all_evidence:
|
||||
fresh_count = 0
|
||||
for e in all_evidence:
|
||||
is_expired = e.valid_until and e.valid_until < now
|
||||
is_stale = e.reviewed_at and (now - e.reviewed_at).days > 90 if hasattr(e, 'reviewed_at') and e.reviewed_at else False
|
||||
if not is_expired and not is_stale:
|
||||
fresh_count += 1
|
||||
evidence_freshness = (fresh_count / len(all_evidence)) * 100
|
||||
else:
|
||||
evidence_freshness = 0.0
|
||||
|
||||
# 5. control_effectiveness: existing formula
|
||||
passed = sum(1 for c in controls if c.status == ControlStatusEnum.PASS)
|
||||
partial = sum(1 for c in controls if c.status == ControlStatusEnum.PARTIAL)
|
||||
control_effectiveness = ((passed + partial * 0.5) / total_controls) * 100
|
||||
|
||||
# 6. overall_readiness: weighted composite
|
||||
overall_readiness = (
|
||||
0.20 * requirement_coverage +
|
||||
0.25 * evidence_strength +
|
||||
0.20 * validation_quality +
|
||||
0.10 * evidence_freshness +
|
||||
0.25 * control_effectiveness
|
||||
)
|
||||
|
||||
# Hard blocks
|
||||
hard_blocks = []
|
||||
|
||||
# Critical controls without any evidence
|
||||
critical_no_evidence = []
|
||||
for c in controls:
|
||||
if c.status in (ControlStatusEnum.PASS, ControlStatusEnum.PARTIAL):
|
||||
evidence_for_ctrl = [e for e in all_evidence if e.control_id == c.id]
|
||||
if not evidence_for_ctrl:
|
||||
critical_no_evidence.append(c.control_id)
|
||||
if critical_no_evidence:
|
||||
hard_blocks.append(
|
||||
f"{len(critical_no_evidence)} Controls mit Status pass/partial haben keine Evidence: "
|
||||
f"{', '.join(critical_no_evidence[:5])}"
|
||||
)
|
||||
|
||||
# Controls with only E0/E1 evidence claiming pass
|
||||
weak_evidence_pass = []
|
||||
for c in controls:
|
||||
if c.status == ControlStatusEnum.PASS:
|
||||
evidence_for_ctrl = [e for e in all_evidence if e.control_id == c.id]
|
||||
if evidence_for_ctrl:
|
||||
max_conf = max(
|
||||
conf_weights.get(
|
||||
e.confidence_level.value if e.confidence_level else "E1", 0.25
|
||||
)
|
||||
for e in evidence_for_ctrl
|
||||
)
|
||||
if max_conf < 0.5: # Only E0 or E1
|
||||
weak_evidence_pass.append(c.control_id)
|
||||
if weak_evidence_pass:
|
||||
hard_blocks.append(
|
||||
f"{len(weak_evidence_pass)} Controls auf 'pass' haben nur E0/E1-Evidence: "
|
||||
f"{', '.join(weak_evidence_pass[:5])}"
|
||||
)
|
||||
|
||||
return {
|
||||
"requirement_coverage": round(requirement_coverage, 1),
|
||||
"evidence_strength": round(evidence_strength, 1),
|
||||
"validation_quality": round(validation_quality, 1),
|
||||
"evidence_freshness": round(evidence_freshness, 1),
|
||||
"control_effectiveness": round(control_effectiveness, 1),
|
||||
"overall_readiness": round(overall_readiness, 1),
|
||||
"hard_blocks": hard_blocks,
|
||||
}
|
||||
|
||||
|
||||
class ControlMappingRepository:
|
||||
"""Repository for requirement-control mappings."""
|
||||
|
||||
80
backend-compliance/compliance/services/assertion_engine.py
Normal file
80
backend-compliance/compliance/services/assertion_engine.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Assertion Engine — splits text into sentences and classifies each.
|
||||
|
||||
Each sentence is tagged as:
|
||||
- assertion: normative statement (pflicht / empfehlung / kann)
|
||||
- fact: references concrete evidence artifacts
|
||||
- rationale: explains why something is required
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from .normative_patterns import (
|
||||
PFLICHT_RE, EMPFEHLUNG_RE, KANN_RE, RATIONALE_RE, EVIDENCE_RE,
|
||||
)
|
||||
|
||||
# Sentence splitter: period/excl/question followed by space+uppercase, or newlines
|
||||
_SENTENCE_SPLIT = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ])|(?:\n\s*\n)')
|
||||
|
||||
|
||||
def extract_assertions(
|
||||
text: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
tenant_id: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Split *text* into sentences and classify each one.
|
||||
|
||||
Returns a list of dicts ready for AssertionDB creation.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
sentences = _SENTENCE_SPLIT.split(text.strip())
|
||||
results: list[dict] = []
|
||||
|
||||
for idx, raw in enumerate(sentences):
|
||||
sentence = raw.strip()
|
||||
if not sentence or len(sentence) < 5:
|
||||
continue
|
||||
|
||||
assertion_type, normative_tier = _classify_sentence(sentence)
|
||||
|
||||
results.append({
|
||||
"tenant_id": tenant_id,
|
||||
"entity_type": entity_type,
|
||||
"entity_id": entity_id,
|
||||
"sentence_text": sentence,
|
||||
"sentence_index": idx,
|
||||
"assertion_type": assertion_type,
|
||||
"normative_tier": normative_tier,
|
||||
"evidence_ids": [],
|
||||
"confidence": 0.0,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _classify_sentence(sentence: str) -> tuple[str, Optional[str]]:
|
||||
"""Return (assertion_type, normative_tier) for a single sentence."""
|
||||
|
||||
# 1. Check for evidence/fact keywords first
|
||||
if EVIDENCE_RE.search(sentence):
|
||||
return ("fact", None)
|
||||
|
||||
# 2. Check for rationale
|
||||
normative_count = len(PFLICHT_RE.findall(sentence)) + len(EMPFEHLUNG_RE.findall(sentence)) + len(KANN_RE.findall(sentence))
|
||||
rationale_count = len(RATIONALE_RE.findall(sentence))
|
||||
if rationale_count > 0 and rationale_count >= normative_count:
|
||||
return ("rationale", None)
|
||||
|
||||
# 3. Normative classification
|
||||
if PFLICHT_RE.search(sentence):
|
||||
return ("assertion", "pflicht")
|
||||
if EMPFEHLUNG_RE.search(sentence):
|
||||
return ("assertion", "empfehlung")
|
||||
if KANN_RE.search(sentence):
|
||||
return ("assertion", "kann")
|
||||
|
||||
# 4. Default: unclassified assertion
|
||||
return ("assertion", None)
|
||||
@@ -493,6 +493,9 @@ class GeneratedControl:
|
||||
applicable_industries: Optional[list] = None # e.g. ["all"] or ["Telekommunikation", "Energie"]
|
||||
applicable_company_size: Optional[list] = None # e.g. ["all"] or ["medium", "large", "enterprise"]
|
||||
scope_conditions: Optional[dict] = None # e.g. {"requires_any": ["uses_ai"], "description": "..."}
|
||||
# Anti-Fake-Evidence: truth tracking for generated controls
|
||||
truth_status: str = "generated"
|
||||
may_be_used_as_evidence: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -781,10 +784,23 @@ REFORM_SYSTEM_PROMPT = """Du bist ein Security-Compliance-Experte. Deine Aufgabe
|
||||
Security Controls zu formulieren. Du formulierst IMMER in eigenen Worten.
|
||||
KOPIERE KEINE Sätze aus dem Quelltext. Verwende eigene Begriffe und Struktur.
|
||||
NENNE NICHT die Quelle. Keine proprietären Bezeichner.
|
||||
|
||||
WICHTIG — Truthfulness-Guardrail:
|
||||
Deine Ausgabe ist ein ENTWURF. Formuliere NIEMALS Behauptungen über bereits erfolgte Umsetzung.
|
||||
Verwende NICHT: "ist compliant", "erfüllt vollständig", "wurde geprüft", "wurde umgesetzt",
|
||||
"ist auditiert", "vollständig implementiert", "nachweislich konform".
|
||||
Verwende stattdessen: "soll umsetzen", "ist vorgesehen", "muss implementiert werden".
|
||||
|
||||
Antworte NUR mit validem JSON. Bei mehreren Controls antworte mit einem JSON-Array."""
|
||||
|
||||
STRUCTURE_SYSTEM_PROMPT = """Du bist ein Security-Compliance-Experte. Strukturiere den gegebenen Text
|
||||
als praxisorientiertes Security Control. Erstelle eine verständliche, umsetzbare Formulierung.
|
||||
|
||||
WICHTIG — Truthfulness-Guardrail:
|
||||
Deine Ausgabe ist ein ENTWURF. Formuliere NIEMALS Behauptungen über bereits erfolgte Umsetzung.
|
||||
Verwende NICHT: "ist compliant", "erfüllt vollständig", "wurde geprüft", "wurde umgesetzt".
|
||||
Verwende stattdessen: "soll umsetzen", "ist vorgesehen", "muss implementiert werden".
|
||||
|
||||
Antworte NUR mit validem JSON. Bei mehreren Controls antworte mit einem JSON-Array."""
|
||||
|
||||
# Shared applicability prompt block — appended to all generation prompts (v3)
|
||||
@@ -1877,7 +1893,38 @@ Kategorien: {CATEGORY_LIST_STR}"""
|
||||
)
|
||||
self.db.commit()
|
||||
row = result.fetchone()
|
||||
return str(row[0]) if row else None
|
||||
control_uuid = str(row[0]) if row else None
|
||||
|
||||
# Anti-Fake-Evidence: Record LLM audit trail for generated control
|
||||
if control_uuid:
|
||||
try:
|
||||
self.db.execute(
|
||||
text("""
|
||||
INSERT INTO compliance_llm_generation_audit (
|
||||
entity_type, entity_id, generation_mode,
|
||||
truth_status, may_be_used_as_evidence,
|
||||
llm_model, llm_provider,
|
||||
input_summary, output_summary
|
||||
) VALUES (
|
||||
'control', :entity_id, 'auto_generation',
|
||||
'generated', FALSE,
|
||||
:llm_model, :llm_provider,
|
||||
:input_summary, :output_summary
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"entity_id": control_uuid,
|
||||
"llm_model": ANTHROPIC_MODEL if ANTHROPIC_API_KEY else OLLAMA_MODEL,
|
||||
"llm_provider": "anthropic" if ANTHROPIC_API_KEY else "ollama",
|
||||
"input_summary": f"Control generation for {control.control_id}",
|
||||
"output_summary": control.title[:500] if control.title else None,
|
||||
},
|
||||
)
|
||||
self.db.commit()
|
||||
except Exception as audit_err:
|
||||
logger.warning("Failed to create LLM audit record: %s", audit_err)
|
||||
|
||||
return control_uuid
|
||||
except Exception as e:
|
||||
logger.error("Failed to store control %s: %s", control.control_id, e)
|
||||
self.db.rollback()
|
||||
|
||||
152
backend-compliance/compliance/services/control_status_machine.py
Normal file
152
backend-compliance/compliance/services/control_status_machine.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Control Status Transition State Machine.
|
||||
|
||||
Enforces that controls cannot be set to "pass" without sufficient evidence.
|
||||
Prevents Compliance-Theater where controls claim compliance without real proof.
|
||||
|
||||
Transition rules:
|
||||
planned → in_progress : always allowed
|
||||
in_progress → pass : requires ≥1 evidence with confidence ≥ E2 and
|
||||
truth_status in (uploaded, observed, validated_internal)
|
||||
in_progress → partial : requires ≥1 evidence (any level)
|
||||
pass → fail : always allowed (degradation)
|
||||
any → n/a : requires status_justification
|
||||
any → planned : always allowed (reset)
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from ..db.models import EvidenceDB
|
||||
|
||||
|
||||
# Confidence level ordering for comparisons
|
||||
CONFIDENCE_ORDER = {"E0": 0, "E1": 1, "E2": 2, "E3": 3, "E4": 4}
|
||||
|
||||
# Truth statuses that qualify as "real" evidence for pass transitions
|
||||
VALID_TRUTH_STATUSES = {"uploaded", "observed", "validated_internal", "accepted_by_auditor", "provided_to_auditor"}
|
||||
|
||||
|
||||
def validate_transition(
|
||||
current_status: str,
|
||||
new_status: str,
|
||||
evidence_list: Optional[List[EvidenceDB]] = None,
|
||||
status_justification: Optional[str] = None,
|
||||
bypass_for_auto_updater: bool = False,
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate whether a control status transition is allowed.
|
||||
|
||||
Args:
|
||||
current_status: Current control status value (e.g. "planned", "pass")
|
||||
new_status: Requested new status
|
||||
evidence_list: List of EvidenceDB objects linked to this control
|
||||
status_justification: Text justification (required for n/a transitions)
|
||||
bypass_for_auto_updater: If True, skip evidence checks (used by CI/CD auto-updater
|
||||
which creates evidence atomically with status change)
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed: bool, violations: list[str])
|
||||
"""
|
||||
violations: List[str] = []
|
||||
evidence_list = evidence_list or []
|
||||
|
||||
# Same status → no-op, always allowed
|
||||
if current_status == new_status:
|
||||
return True, []
|
||||
|
||||
# Reset to planned is always allowed
|
||||
if new_status == "planned":
|
||||
return True, []
|
||||
|
||||
# n/a requires justification
|
||||
if new_status == "n/a":
|
||||
if not status_justification or not status_justification.strip():
|
||||
violations.append("Transition to 'n/a' requires a status_justification explaining why this control is not applicable.")
|
||||
return len(violations) == 0, violations
|
||||
|
||||
# Degradation: pass → fail is always allowed
|
||||
if current_status == "pass" and new_status == "fail":
|
||||
return True, []
|
||||
|
||||
# planned → in_progress: always allowed
|
||||
if current_status == "planned" and new_status == "in_progress":
|
||||
return True, []
|
||||
|
||||
# in_progress → partial: needs at least 1 evidence
|
||||
if new_status == "partial":
|
||||
if not bypass_for_auto_updater and len(evidence_list) == 0:
|
||||
violations.append("Transition to 'partial' requires at least 1 evidence record.")
|
||||
return len(violations) == 0, violations
|
||||
|
||||
# in_progress → pass: strict requirements
|
||||
if new_status == "pass":
|
||||
if bypass_for_auto_updater:
|
||||
return True, []
|
||||
|
||||
if len(evidence_list) == 0:
|
||||
violations.append("Transition to 'pass' requires at least 1 evidence record.")
|
||||
return False, violations
|
||||
|
||||
# Check for at least one qualifying evidence
|
||||
has_qualifying = False
|
||||
for e in evidence_list:
|
||||
conf = getattr(e, "confidence_level", None)
|
||||
truth = getattr(e, "truth_status", None)
|
||||
|
||||
# Get string values from enum or string
|
||||
conf_val = conf.value if hasattr(conf, "value") else str(conf) if conf else "E1"
|
||||
truth_val = truth.value if hasattr(truth, "value") else str(truth) if truth else "uploaded"
|
||||
|
||||
if CONFIDENCE_ORDER.get(conf_val, 1) >= CONFIDENCE_ORDER["E2"] and truth_val in VALID_TRUTH_STATUSES:
|
||||
has_qualifying = True
|
||||
break
|
||||
|
||||
if not has_qualifying:
|
||||
violations.append(
|
||||
"Transition to 'pass' requires at least 1 evidence with confidence >= E2 "
|
||||
"and truth_status in (uploaded, observed, validated_internal, accepted_by_auditor). "
|
||||
"Current evidence does not meet this threshold."
|
||||
)
|
||||
|
||||
return len(violations) == 0, violations
|
||||
|
||||
# in_progress → fail: always allowed
|
||||
if new_status == "fail":
|
||||
return True, []
|
||||
|
||||
# Any other transition from planned/fail to pass requires going through in_progress
|
||||
if current_status in ("planned", "fail") and new_status == "pass":
|
||||
if bypass_for_auto_updater:
|
||||
return True, []
|
||||
violations.append(
|
||||
f"Direct transition from '{current_status}' to 'pass' is not allowed. "
|
||||
f"Move to 'in_progress' first, then to 'pass' with qualifying evidence."
|
||||
)
|
||||
return False, violations
|
||||
|
||||
# Default: allow other transitions (e.g. fail → partial, partial → pass)
|
||||
# For partial → pass, apply the same evidence checks
|
||||
if current_status == "partial" and new_status == "pass":
|
||||
if bypass_for_auto_updater:
|
||||
return True, []
|
||||
|
||||
has_qualifying = False
|
||||
for e in evidence_list:
|
||||
conf = getattr(e, "confidence_level", None)
|
||||
truth = getattr(e, "truth_status", None)
|
||||
conf_val = conf.value if hasattr(conf, "value") else str(conf) if conf else "E1"
|
||||
truth_val = truth.value if hasattr(truth, "value") else str(truth) if truth else "uploaded"
|
||||
|
||||
if CONFIDENCE_ORDER.get(conf_val, 1) >= CONFIDENCE_ORDER["E2"] and truth_val in VALID_TRUTH_STATUSES:
|
||||
has_qualifying = True
|
||||
break
|
||||
|
||||
if not has_qualifying:
|
||||
violations.append(
|
||||
"Transition from 'partial' to 'pass' requires at least 1 evidence with confidence >= E2 "
|
||||
"and truth_status in (uploaded, observed, validated_internal, accepted_by_auditor)."
|
||||
)
|
||||
return len(violations) == 0, violations
|
||||
|
||||
# All other transitions allowed
|
||||
return True, []
|
||||
@@ -52,64 +52,18 @@ ANTHROPIC_API_URL = "https://api.anthropic.com/v1"
|
||||
# Tier 2: Empfehlung (recommendation) — weaker normative signals
|
||||
# Tier 3: Kann (optional/permissive) — permissive signals
|
||||
# Nothing is rejected — everything is classified.
|
||||
#
|
||||
# Patterns are defined in normative_patterns.py and imported here
|
||||
# with local aliases for backward compatibility.
|
||||
|
||||
_PFLICHT_SIGNALS = [
|
||||
# Deutsche modale Pflichtformulierungen
|
||||
r"\bmüssen\b", r"\bmuss\b", r"\bhat\s+sicherzustellen\b",
|
||||
r"\bhaben\s+sicherzustellen\b", r"\bsind\s+verpflichtet\b",
|
||||
r"\bist\s+verpflichtet\b",
|
||||
# "ist zu prüfen", "sind zu dokumentieren" (direkt)
|
||||
r"\bist\s+zu\s+\w+en\b", r"\bsind\s+zu\s+\w+en\b",
|
||||
r"\bhat\s+zu\s+\w+en\b", r"\bhaben\s+zu\s+\w+en\b",
|
||||
# "ist festzustellen", "sind vorzunehmen" (Compound-Verben, eingebettetes zu)
|
||||
r"\bist\s+\w+zu\w+en\b", r"\bsind\s+\w+zu\w+en\b",
|
||||
# "ist zusätzlich zu prüfen", "sind regelmäßig zu überwachen" (Adverb dazwischen)
|
||||
r"\bist\s+\w+\s+zu\s+\w+en\b", r"\bsind\s+\w+\s+zu\s+\w+en\b",
|
||||
r"\bhat\s+\w+\s+zu\s+\w+en\b", r"\bhaben\s+\w+\s+zu\s+\w+en\b",
|
||||
# Englische Pflicht-Signale
|
||||
r"\bshall\b", r"\bmust\b", r"\brequired\b",
|
||||
# Compound-Infinitive (Gerundivum): mitzuteilen, anzuwenden, bereitzustellen
|
||||
r"\b\w+zuteilen\b", r"\b\w+zuwenden\b", r"\b\w+zustellen\b", r"\b\w+zulegen\b",
|
||||
r"\b\w+zunehmen\b", r"\b\w+zuführen\b", r"\b\w+zuhalten\b", r"\b\w+zusetzen\b",
|
||||
r"\b\w+zuweisen\b", r"\b\w+zuordnen\b", r"\b\w+zufügen\b", r"\b\w+zugeben\b",
|
||||
# Breites Pattern: "ist ... [bis 80 Zeichen] ... zu + Infinitiv"
|
||||
r"\bist\b.{1,80}\bzu\s+\w+en\b", r"\bsind\b.{1,80}\bzu\s+\w+en\b",
|
||||
]
|
||||
_PFLICHT_RE = re.compile("|".join(_PFLICHT_SIGNALS), re.IGNORECASE)
|
||||
|
||||
_EMPFEHLUNG_SIGNALS = [
|
||||
# Modale Verben (schwaecher als "muss")
|
||||
r"\bsoll\b", r"\bsollen\b", r"\bsollte\b", r"\bsollten\b",
|
||||
r"\bgewährleisten\b", r"\bsicherstellen\b",
|
||||
# Englische Empfehlungs-Signale
|
||||
r"\bshould\b", r"\bensure\b", r"\brecommend\w*\b",
|
||||
# Haeufige normative Infinitive (ohne Hilfsverb, als Empfehlung)
|
||||
r"\bnachweisen\b", r"\beinhalten\b", r"\bunterlassen\b", r"\bwahren\b",
|
||||
r"\bdokumentieren\b", r"\bimplementieren\b", r"\büberprüfen\b", r"\büberwachen\b",
|
||||
# Pruefanweisungen als normative Aussage
|
||||
r"\bprüfen,\s+ob\b", r"\bkontrollieren,\s+ob\b",
|
||||
]
|
||||
_EMPFEHLUNG_RE = re.compile("|".join(_EMPFEHLUNG_SIGNALS), re.IGNORECASE)
|
||||
|
||||
_KANN_SIGNALS = [
|
||||
r"\bkann\b", r"\bkönnen\b", r"\bdarf\b", r"\bdürfen\b",
|
||||
r"\bmay\b", r"\boptional\b",
|
||||
]
|
||||
_KANN_RE = re.compile("|".join(_KANN_SIGNALS), re.IGNORECASE)
|
||||
|
||||
# Union of all normative signals (for backward-compatible has_normative_signal flag)
|
||||
_NORMATIVE_RE = re.compile(
|
||||
"|".join(_PFLICHT_SIGNALS + _EMPFEHLUNG_SIGNALS + _KANN_SIGNALS),
|
||||
re.IGNORECASE,
|
||||
from .normative_patterns import (
|
||||
PFLICHT_RE as _PFLICHT_RE,
|
||||
EMPFEHLUNG_RE as _EMPFEHLUNG_RE,
|
||||
KANN_RE as _KANN_RE,
|
||||
NORMATIVE_RE as _NORMATIVE_RE,
|
||||
RATIONALE_RE as _RATIONALE_RE,
|
||||
)
|
||||
|
||||
_RATIONALE_SIGNALS = [
|
||||
r"\bda\s+", r"\bweil\b", r"\bgrund\b", r"\berwägung",
|
||||
r"\bbecause\b", r"\breason\b", r"\brationale\b",
|
||||
r"\bkönnen\s+.*\s+verursachen\b", r"\bführt\s+zu\b",
|
||||
]
|
||||
_RATIONALE_RE = re.compile("|".join(_RATIONALE_SIGNALS), re.IGNORECASE)
|
||||
|
||||
_TEST_SIGNALS = [
|
||||
r"\btesten\b", r"\btest\b", r"\bprüfung\b", r"\bprüfen\b",
|
||||
r"\bgetestet\b", r"\bwirksamkeit\b", r"\baudit\b",
|
||||
|
||||
59
backend-compliance/compliance/services/normative_patterns.py
Normal file
59
backend-compliance/compliance/services/normative_patterns.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Shared normative language patterns for assertion classification.
|
||||
|
||||
Extracted from decomposition_pass.py for reuse in the assertion engine.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_PFLICHT_SIGNALS = [
|
||||
r"\bmüssen\b", r"\bmuss\b", r"\bhat\s+sicherzustellen\b",
|
||||
r"\bhaben\s+sicherzustellen\b", r"\bsind\s+verpflichtet\b",
|
||||
r"\bist\s+verpflichtet\b",
|
||||
r"\bist\s+zu\s+\w+en\b", r"\bsind\s+zu\s+\w+en\b",
|
||||
r"\bhat\s+zu\s+\w+en\b", r"\bhaben\s+zu\s+\w+en\b",
|
||||
r"\bist\s+\w+zu\w+en\b", r"\bsind\s+\w+zu\w+en\b",
|
||||
r"\bist\s+\w+\s+zu\s+\w+en\b", r"\bsind\s+\w+\s+zu\s+\w+en\b",
|
||||
r"\bhat\s+\w+\s+zu\s+\w+en\b", r"\bhaben\s+\w+\s+zu\s+\w+en\b",
|
||||
r"\bshall\b", r"\bmust\b", r"\brequired\b",
|
||||
r"\b\w+zuteilen\b", r"\b\w+zuwenden\b", r"\b\w+zustellen\b", r"\b\w+zulegen\b",
|
||||
r"\b\w+zunehmen\b", r"\b\w+zuführen\b", r"\b\w+zuhalten\b", r"\b\w+zusetzen\b",
|
||||
r"\b\w+zuweisen\b", r"\b\w+zuordnen\b", r"\b\w+zufügen\b", r"\b\w+zugeben\b",
|
||||
r"\bist\b.{1,80}\bzu\s+\w+en\b", r"\bsind\b.{1,80}\bzu\s+\w+en\b",
|
||||
]
|
||||
PFLICHT_RE = re.compile("|".join(_PFLICHT_SIGNALS), re.IGNORECASE)
|
||||
|
||||
_EMPFEHLUNG_SIGNALS = [
|
||||
r"\bsoll\b", r"\bsollen\b", r"\bsollte\b", r"\bsollten\b",
|
||||
r"\bgewährleisten\b", r"\bsicherstellen\b",
|
||||
r"\bshould\b", r"\bensure\b", r"\brecommend\w*\b",
|
||||
r"\bnachweisen\b", r"\beinhalten\b", r"\bunterlassen\b", r"\bwahren\b",
|
||||
r"\bdokumentieren\b", r"\bimplementieren\b", r"\büberprüfen\b", r"\büberwachen\b",
|
||||
r"\bprüfen,\s+ob\b", r"\bkontrollieren,\s+ob\b",
|
||||
]
|
||||
EMPFEHLUNG_RE = re.compile("|".join(_EMPFEHLUNG_SIGNALS), re.IGNORECASE)
|
||||
|
||||
_KANN_SIGNALS = [
|
||||
r"\bkann\b", r"\bkönnen\b", r"\bdarf\b", r"\bdürfen\b",
|
||||
r"\bmay\b", r"\boptional\b",
|
||||
]
|
||||
KANN_RE = re.compile("|".join(_KANN_SIGNALS), re.IGNORECASE)
|
||||
|
||||
NORMATIVE_RE = re.compile(
|
||||
"|".join(_PFLICHT_SIGNALS + _EMPFEHLUNG_SIGNALS + _KANN_SIGNALS),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_RATIONALE_SIGNALS = [
|
||||
r"\bda\s+", r"\bweil\b", r"\bgrund\b", r"\berwägung",
|
||||
r"\bbecause\b", r"\breason\b", r"\brationale\b",
|
||||
r"\bkönnen\s+.*\s+verursachen\b", r"\bführt\s+zu\b",
|
||||
]
|
||||
RATIONALE_RE = re.compile("|".join(_RATIONALE_SIGNALS), re.IGNORECASE)
|
||||
|
||||
# Evidence-related keywords (for fact detection)
|
||||
_EVIDENCE_KEYWORDS = [
|
||||
r"\bnachweis\b", r"\bzertifikat\b", r"\baudit.report\b",
|
||||
r"\bprotokoll\b", r"\bdokumentation\b", r"\bbericht\b",
|
||||
r"\bcertificate\b", r"\bevidence\b", r"\bproof\b",
|
||||
]
|
||||
EVIDENCE_RE = re.compile("|".join(_EVIDENCE_KEYWORDS), re.IGNORECASE)
|
||||
125
backend-compliance/migrations/076_anti_fake_evidence.sql
Normal file
125
backend-compliance/migrations/076_anti_fake_evidence.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
-- Migration 076: Anti-Fake-Evidence Guardrails (Phase 1)
|
||||
--
|
||||
-- Prevents "Compliance-Theater": generated content passed off as real evidence,
|
||||
-- controls without evidence marked as "pass", unvalidated 100% compliance claims.
|
||||
--
|
||||
-- Changes:
|
||||
-- 1. New ENUM types for evidence confidence + truth status
|
||||
-- 2. New columns on compliance_evidence (confidence, truth, review tracking)
|
||||
-- 3. New value 'in_progress' for controlstatusenum
|
||||
-- 4. status_justification column on compliance_controls
|
||||
-- 5. New table compliance_llm_generation_audit
|
||||
-- 6. Backfill existing evidence based on source
|
||||
-- 7. Indexes on new columns
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. New ENUM types
|
||||
-- ============================================================================
|
||||
|
||||
-- NOTE: CREATE TYPE cannot run inside a transaction block when combined with
|
||||
-- ALTER TYPE ... ADD VALUE. Each statement here is auto-committed separately
|
||||
-- when executed outside a transaction (which is the default for psql scripts).
|
||||
|
||||
CREATE TYPE evidence_confidence_level AS ENUM (
|
||||
'E0', -- Generated / no real evidence (LLM output, placeholder)
|
||||
'E1', -- Uploaded but unreviewed (manual upload, no hash, no reviewer)
|
||||
'E2', -- Reviewed internally (human reviewed, hash verified)
|
||||
'E3', -- Observed by system (CI/CD pipeline, API with hash)
|
||||
'E4' -- Validated by external auditor
|
||||
);
|
||||
|
||||
CREATE TYPE evidence_truth_status AS ENUM (
|
||||
'generated', -- Created by LLM / system generation
|
||||
'uploaded', -- Manually uploaded by user
|
||||
'observed', -- Automatically observed (CI/CD, monitoring)
|
||||
'validated_internal', -- Reviewed + approved by internal reviewer
|
||||
'rejected', -- Reviewed and rejected
|
||||
'provided_to_auditor', -- Shared with external auditor
|
||||
'accepted_by_auditor' -- Accepted by external auditor
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Add 'in_progress' to controlstatusenum
|
||||
-- ============================================================================
|
||||
-- ALTER TYPE ... ADD VALUE cannot run inside a transaction.
|
||||
|
||||
ALTER TYPE controlstatusenum ADD VALUE IF NOT EXISTS 'in_progress';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. New columns on compliance_evidence
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE compliance_evidence
|
||||
ADD COLUMN IF NOT EXISTS confidence_level evidence_confidence_level DEFAULT 'E1',
|
||||
ADD COLUMN IF NOT EXISTS truth_status evidence_truth_status DEFAULT 'uploaded',
|
||||
ADD COLUMN IF NOT EXISTS generation_mode VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS may_be_used_as_evidence BOOLEAN DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_by VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. status_justification on compliance_controls
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE compliance_controls
|
||||
ADD COLUMN IF NOT EXISTS status_justification TEXT;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. LLM Generation Audit table
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance_llm_generation_audit (
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
tenant_id VARCHAR(36),
|
||||
entity_type VARCHAR(50) NOT NULL, -- 'evidence', 'control', 'document', ...
|
||||
entity_id VARCHAR(36), -- FK to the generated entity
|
||||
generation_mode VARCHAR(100) NOT NULL, -- 'draft_assistance', 'auto_generation', ...
|
||||
truth_status evidence_truth_status NOT NULL DEFAULT 'generated',
|
||||
may_be_used_as_evidence BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
llm_model VARCHAR(100),
|
||||
llm_provider VARCHAR(50), -- 'ollama', 'anthropic', ...
|
||||
prompt_hash VARCHAR(64), -- SHA-256 of the prompt
|
||||
input_summary TEXT, -- Truncated input for auditability
|
||||
output_summary TEXT, -- Truncated output for auditability
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Backfill existing evidence based on source
|
||||
-- ============================================================================
|
||||
|
||||
-- CI pipeline evidence → E3 + observed
|
||||
UPDATE compliance_evidence
|
||||
SET confidence_level = 'E3',
|
||||
truth_status = 'observed'
|
||||
WHERE source = 'ci_pipeline'
|
||||
AND confidence_level = 'E1';
|
||||
|
||||
-- API evidence → E3 + observed
|
||||
UPDATE compliance_evidence
|
||||
SET confidence_level = 'E3',
|
||||
truth_status = 'observed'
|
||||
WHERE source = 'api'
|
||||
AND confidence_level = 'E1';
|
||||
|
||||
-- Manual/upload evidence stays at E1 + uploaded (default)
|
||||
|
||||
-- Generated evidence → E0 + generated
|
||||
UPDATE compliance_evidence
|
||||
SET confidence_level = 'E0',
|
||||
truth_status = 'generated',
|
||||
may_be_used_as_evidence = FALSE
|
||||
WHERE source = 'generated'
|
||||
AND confidence_level = 'E1';
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_confidence ON compliance_evidence (confidence_level);
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_truth_status ON compliance_evidence (truth_status);
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_may_be_used ON compliance_evidence (may_be_used_as_evidence);
|
||||
CREATE INDEX IF NOT EXISTS ix_llm_audit_entity ON compliance_llm_generation_audit (entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_llm_audit_tenant ON compliance_llm_generation_audit (tenant_id);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Migration 077: Anti-Fake-Evidence Phase 2
|
||||
-- Assertions table, Four-Eyes columns on Evidence, Audit-Trail performance index
|
||||
|
||||
-- 1A. Assertions table
|
||||
CREATE TABLE IF NOT EXISTS compliance_assertions (
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
tenant_id VARCHAR(36),
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id VARCHAR(36) NOT NULL,
|
||||
sentence_text TEXT NOT NULL,
|
||||
sentence_index INTEGER NOT NULL DEFAULT 0,
|
||||
assertion_type VARCHAR(20) NOT NULL DEFAULT 'assertion',
|
||||
evidence_ids JSONB DEFAULT '[]'::jsonb,
|
||||
confidence FLOAT DEFAULT 0.0,
|
||||
normative_tier VARCHAR(20),
|
||||
verified_by VARCHAR(200),
|
||||
verified_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_assertion_entity ON compliance_assertions (entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_assertion_type ON compliance_assertions (assertion_type);
|
||||
CREATE INDEX IF NOT EXISTS ix_assertion_tenant ON compliance_assertions (tenant_id);
|
||||
|
||||
-- 1B. Four-Eyes columns on Evidence
|
||||
ALTER TABLE compliance_evidence
|
||||
ADD COLUMN IF NOT EXISTS approval_status VARCHAR(30) DEFAULT 'none',
|
||||
ADD COLUMN IF NOT EXISTS first_reviewer VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS first_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS second_reviewer VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS second_reviewed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS requires_four_eyes BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_approval_status ON compliance_evidence (approval_status);
|
||||
|
||||
-- 1C. Audit-Trail performance index
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_trail_entity_action
|
||||
ON compliance_audit_trail (entity_type, action, performed_at);
|
||||
562
backend-compliance/tests/test_anti_fake_evidence.py
Normal file
562
backend-compliance/tests/test_anti_fake_evidence.py
Normal file
@@ -0,0 +1,562 @@
|
||||
"""Tests for Anti-Fake-Evidence Phase 1 guardrails.
|
||||
|
||||
~45 tests covering:
|
||||
- Evidence confidence classification
|
||||
- Evidence truth status classification
|
||||
- Control status transition state machine
|
||||
- Multi-dimensional compliance score
|
||||
- LLM generation audit
|
||||
- Evidence review endpoint
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.evidence_routes import router as evidence_router
|
||||
from compliance.api.llm_audit_routes import router as llm_audit_router
|
||||
from compliance.api.evidence_routes import _classify_confidence, _classify_truth_status
|
||||
from compliance.services.control_status_machine import validate_transition
|
||||
from compliance.db.models import (
|
||||
EvidenceConfidenceEnum,
|
||||
EvidenceTruthStatusEnum,
|
||||
ControlStatusEnum,
|
||||
)
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup with mocked DB dependency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(evidence_router)
|
||||
app.include_router(llm_audit_router, prefix="/compliance")
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
EVIDENCE_UUID = "eeeeeeee-aaaa-bbbb-cccc-ffffffffffff"
|
||||
CONTROL_UUID = "cccccccc-aaaa-bbbb-cccc-dddddddddddd"
|
||||
NOW = datetime(2026, 3, 23, 12, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_evidence(overrides=None):
|
||||
e = MagicMock()
|
||||
e.id = EVIDENCE_UUID
|
||||
e.control_id = CONTROL_UUID
|
||||
e.evidence_type = "test_results"
|
||||
e.title = "Pytest Test Report"
|
||||
e.description = "All tests passing"
|
||||
e.artifact_url = "https://ci.example.com/job/123/artifact"
|
||||
e.artifact_path = None
|
||||
e.artifact_hash = "abc123def456"
|
||||
e.file_size_bytes = None
|
||||
e.mime_type = None
|
||||
e.status = MagicMock()
|
||||
e.status.value = "valid"
|
||||
e.uploaded_by = None
|
||||
e.source = "ci_pipeline"
|
||||
e.ci_job_id = "job-123"
|
||||
e.valid_from = NOW
|
||||
e.valid_until = NOW + timedelta(days=90)
|
||||
e.collected_at = NOW
|
||||
e.created_at = NOW
|
||||
# Anti-fake-evidence fields
|
||||
e.confidence_level = EvidenceConfidenceEnum.E3
|
||||
e.truth_status = EvidenceTruthStatusEnum.OBSERVED
|
||||
e.generation_mode = None
|
||||
e.may_be_used_as_evidence = True
|
||||
e.reviewed_by = None
|
||||
e.reviewed_at = None
|
||||
# Phase 2 fields
|
||||
e.approval_status = "none"
|
||||
e.first_reviewer = None
|
||||
e.first_reviewed_at = None
|
||||
e.second_reviewer = None
|
||||
e.second_reviewed_at = None
|
||||
e.requires_four_eyes = False
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
setattr(e, k, v)
|
||||
return e
|
||||
|
||||
|
||||
def make_control(overrides=None):
|
||||
c = MagicMock()
|
||||
c.id = CONTROL_UUID
|
||||
c.control_id = "GOV-001"
|
||||
c.title = "Access Control"
|
||||
c.status = ControlStatusEnum.PLANNED
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
setattr(c, k, v)
|
||||
return c
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. TestEvidenceConfidenceClassification
|
||||
# ===========================================================================
|
||||
|
||||
class TestEvidenceConfidenceClassification:
|
||||
"""Test automatic confidence level classification."""
|
||||
|
||||
def test_ci_pipeline_returns_e3(self):
|
||||
assert _classify_confidence("ci_pipeline") == EvidenceConfidenceEnum.E3
|
||||
|
||||
def test_api_with_hash_returns_e3(self):
|
||||
assert _classify_confidence("api", artifact_hash="sha256:abc") == EvidenceConfidenceEnum.E3
|
||||
|
||||
def test_api_without_hash_returns_e3(self):
|
||||
assert _classify_confidence("api") == EvidenceConfidenceEnum.E3
|
||||
|
||||
def test_manual_returns_e1(self):
|
||||
assert _classify_confidence("manual") == EvidenceConfidenceEnum.E1
|
||||
|
||||
def test_upload_returns_e1(self):
|
||||
assert _classify_confidence("upload") == EvidenceConfidenceEnum.E1
|
||||
|
||||
def test_generated_returns_e0(self):
|
||||
assert _classify_confidence("generated") == EvidenceConfidenceEnum.E0
|
||||
|
||||
def test_unknown_source_returns_e1(self):
|
||||
assert _classify_confidence("some_random_source") == EvidenceConfidenceEnum.E1
|
||||
|
||||
def test_none_source_returns_e1(self):
|
||||
assert _classify_confidence(None) == EvidenceConfidenceEnum.E1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. TestEvidenceTruthStatus
|
||||
# ===========================================================================
|
||||
|
||||
class TestEvidenceTruthStatus:
|
||||
"""Test automatic truth status classification."""
|
||||
|
||||
def test_ci_pipeline_returns_observed(self):
|
||||
assert _classify_truth_status("ci_pipeline") == EvidenceTruthStatusEnum.OBSERVED
|
||||
|
||||
def test_manual_returns_uploaded(self):
|
||||
assert _classify_truth_status("manual") == EvidenceTruthStatusEnum.UPLOADED
|
||||
|
||||
def test_upload_returns_uploaded(self):
|
||||
assert _classify_truth_status("upload") == EvidenceTruthStatusEnum.UPLOADED
|
||||
|
||||
def test_generated_returns_generated(self):
|
||||
assert _classify_truth_status("generated") == EvidenceTruthStatusEnum.GENERATED
|
||||
|
||||
def test_api_returns_observed(self):
|
||||
assert _classify_truth_status("api") == EvidenceTruthStatusEnum.OBSERVED
|
||||
|
||||
def test_none_returns_uploaded(self):
|
||||
assert _classify_truth_status(None) == EvidenceTruthStatusEnum.UPLOADED
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. TestControlStatusTransitions
|
||||
# ===========================================================================
|
||||
|
||||
class TestControlStatusTransitions:
|
||||
"""Test the control status transition state machine."""
|
||||
|
||||
def test_planned_to_in_progress_allowed(self):
|
||||
allowed, violations = validate_transition("planned", "in_progress")
|
||||
assert allowed is True
|
||||
assert violations == []
|
||||
|
||||
def test_in_progress_to_pass_without_evidence_blocked(self):
|
||||
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[])
|
||||
assert allowed is False
|
||||
assert len(violations) > 0
|
||||
assert "pass" in violations[0].lower()
|
||||
|
||||
def test_in_progress_to_pass_with_e2_evidence_allowed(self):
|
||||
e = make_evidence({
|
||||
"confidence_level": EvidenceConfidenceEnum.E2,
|
||||
"truth_status": EvidenceTruthStatusEnum.VALIDATED_INTERNAL,
|
||||
})
|
||||
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[e])
|
||||
assert allowed is True
|
||||
assert violations == []
|
||||
|
||||
def test_in_progress_to_pass_with_e1_evidence_blocked(self):
|
||||
e = make_evidence({
|
||||
"confidence_level": EvidenceConfidenceEnum.E1,
|
||||
"truth_status": EvidenceTruthStatusEnum.UPLOADED,
|
||||
})
|
||||
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[e])
|
||||
assert allowed is False
|
||||
assert "E2" in violations[0]
|
||||
|
||||
def test_in_progress_to_partial_with_evidence_allowed(self):
|
||||
e = make_evidence({"confidence_level": EvidenceConfidenceEnum.E0})
|
||||
allowed, violations = validate_transition("in_progress", "partial", evidence_list=[e])
|
||||
assert allowed is True
|
||||
|
||||
def test_in_progress_to_partial_without_evidence_blocked(self):
|
||||
allowed, violations = validate_transition("in_progress", "partial", evidence_list=[])
|
||||
assert allowed is False
|
||||
|
||||
def test_pass_to_fail_always_allowed(self):
|
||||
allowed, violations = validate_transition("pass", "fail")
|
||||
assert allowed is True
|
||||
|
||||
def test_any_to_na_requires_justification(self):
|
||||
allowed, violations = validate_transition("in_progress", "n/a", status_justification=None)
|
||||
assert allowed is False
|
||||
assert "justification" in violations[0].lower()
|
||||
|
||||
def test_any_to_na_with_justification_allowed(self):
|
||||
allowed, violations = validate_transition("in_progress", "n/a", status_justification="Not applicable for this project")
|
||||
assert allowed is True
|
||||
|
||||
def test_any_to_planned_always_allowed(self):
|
||||
allowed, violations = validate_transition("pass", "planned")
|
||||
assert allowed is True
|
||||
|
||||
def test_same_status_noop_allowed(self):
|
||||
allowed, violations = validate_transition("pass", "pass")
|
||||
assert allowed is True
|
||||
|
||||
def test_bypass_for_auto_updater(self):
|
||||
allowed, violations = validate_transition("in_progress", "pass", evidence_list=[], bypass_for_auto_updater=True)
|
||||
assert allowed is True
|
||||
|
||||
def test_partial_to_pass_needs_e2(self):
|
||||
e = make_evidence({
|
||||
"confidence_level": EvidenceConfidenceEnum.E1,
|
||||
"truth_status": EvidenceTruthStatusEnum.UPLOADED,
|
||||
})
|
||||
allowed, violations = validate_transition("partial", "pass", evidence_list=[e])
|
||||
assert allowed is False
|
||||
|
||||
def test_partial_to_pass_with_e3_allowed(self):
|
||||
e = make_evidence({
|
||||
"confidence_level": EvidenceConfidenceEnum.E3,
|
||||
"truth_status": EvidenceTruthStatusEnum.OBSERVED,
|
||||
})
|
||||
allowed, violations = validate_transition("partial", "pass", evidence_list=[e])
|
||||
assert allowed is True
|
||||
|
||||
def test_in_progress_to_fail_allowed(self):
|
||||
allowed, violations = validate_transition("in_progress", "fail")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. TestMultiDimensionalScore
|
||||
# ===========================================================================
|
||||
|
||||
class TestMultiDimensionalScore:
|
||||
"""Test multi-dimensional score calculation."""
|
||||
|
||||
def test_score_structure(self):
|
||||
"""Score result should have all required keys."""
|
||||
from compliance.db.repository import ControlRepository
|
||||
repo = ControlRepository(mock_db)
|
||||
|
||||
with patch.object(repo, 'get_all', return_value=[]):
|
||||
result = repo.get_multi_dimensional_score()
|
||||
|
||||
assert "requirement_coverage" in result
|
||||
assert "evidence_strength" in result
|
||||
assert "validation_quality" in result
|
||||
assert "evidence_freshness" in result
|
||||
assert "control_effectiveness" in result
|
||||
assert "overall_readiness" in result
|
||||
assert "hard_blocks" in result
|
||||
|
||||
def test_empty_controls_returns_zeros(self):
|
||||
from compliance.db.repository import ControlRepository
|
||||
repo = ControlRepository(mock_db)
|
||||
|
||||
with patch.object(repo, 'get_all', return_value=[]):
|
||||
result = repo.get_multi_dimensional_score()
|
||||
|
||||
assert result["overall_readiness"] == 0.0
|
||||
assert "Keine Controls" in result["hard_blocks"][0]
|
||||
|
||||
def test_hard_blocks_pass_without_evidence(self):
|
||||
"""Controls on 'pass' without evidence should trigger hard block."""
|
||||
from compliance.db.repository import ControlRepository
|
||||
repo = ControlRepository(mock_db)
|
||||
|
||||
ctrl = make_control({"status": ControlStatusEnum.PASS})
|
||||
mock_db.query.return_value.all.return_value = [] # no evidence
|
||||
mock_db.query.return_value.scalar.return_value = 0
|
||||
|
||||
with patch.object(repo, 'get_all', return_value=[ctrl]):
|
||||
result = repo.get_multi_dimensional_score()
|
||||
|
||||
assert any("Evidence" in b or "evidence" in b.lower() for b in result["hard_blocks"])
|
||||
|
||||
def test_all_dimensions_are_floats(self):
|
||||
from compliance.db.repository import ControlRepository
|
||||
repo = ControlRepository(mock_db)
|
||||
|
||||
with patch.object(repo, 'get_all', return_value=[]):
|
||||
result = repo.get_multi_dimensional_score()
|
||||
|
||||
for key in ["requirement_coverage", "evidence_strength", "validation_quality",
|
||||
"evidence_freshness", "control_effectiveness", "overall_readiness"]:
|
||||
assert isinstance(result[key], float), f"{key} should be float"
|
||||
|
||||
def test_hard_blocks_is_list(self):
|
||||
from compliance.db.repository import ControlRepository
|
||||
repo = ControlRepository(mock_db)
|
||||
|
||||
with patch.object(repo, 'get_all', return_value=[]):
|
||||
result = repo.get_multi_dimensional_score()
|
||||
|
||||
assert isinstance(result["hard_blocks"], list)
|
||||
|
||||
def test_backwards_compatibility_with_old_score(self):
|
||||
"""get_statistics should still work and return compliance_score."""
|
||||
from compliance.db.repository import ControlRepository
|
||||
repo = ControlRepository(mock_db)
|
||||
|
||||
mock_db.query.return_value.scalar.return_value = 0
|
||||
mock_db.query.return_value.group_by.return_value.all.return_value = []
|
||||
|
||||
result = repo.get_statistics()
|
||||
assert "compliance_score" in result
|
||||
assert "total" in result
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. TestForbiddenFormulations
|
||||
# ===========================================================================
|
||||
|
||||
class TestForbiddenFormulations:
|
||||
"""Test forbidden formulation detection (tested via the validate endpoint context)."""
|
||||
|
||||
def test_import_works(self):
|
||||
"""Verify forbidden pattern check function is importable and callable."""
|
||||
# This tests the Python-side schema, the actual check is in TypeScript
|
||||
from compliance.api.schemas import MultiDimensionalScore, StatusTransitionError
|
||||
score = MultiDimensionalScore()
|
||||
assert score.overall_readiness == 0.0
|
||||
err = StatusTransitionError(current_status="planned", requested_status="pass")
|
||||
assert err.allowed is False
|
||||
|
||||
def test_status_transition_error_schema(self):
|
||||
from compliance.api.schemas import StatusTransitionError
|
||||
err = StatusTransitionError(
|
||||
allowed=False,
|
||||
current_status="in_progress",
|
||||
requested_status="pass",
|
||||
violations=["Need E2 evidence"],
|
||||
)
|
||||
assert err.violations == ["Need E2 evidence"]
|
||||
|
||||
def test_multi_dimensional_score_defaults(self):
|
||||
from compliance.api.schemas import MultiDimensionalScore
|
||||
score = MultiDimensionalScore()
|
||||
assert score.requirement_coverage == 0.0
|
||||
assert score.hard_blocks == []
|
||||
|
||||
def test_multi_dimensional_score_with_data(self):
|
||||
from compliance.api.schemas import MultiDimensionalScore
|
||||
score = MultiDimensionalScore(
|
||||
requirement_coverage=80.0,
|
||||
evidence_strength=60.0,
|
||||
validation_quality=40.0,
|
||||
evidence_freshness=90.0,
|
||||
control_effectiveness=70.0,
|
||||
overall_readiness=65.0,
|
||||
hard_blocks=["3 Controls ohne Evidence"],
|
||||
)
|
||||
assert score.overall_readiness == 65.0
|
||||
assert len(score.hard_blocks) == 1
|
||||
|
||||
def test_evidence_response_has_anti_fake_fields(self):
|
||||
from compliance.api.schemas import EvidenceResponse
|
||||
fields = EvidenceResponse.model_fields
|
||||
assert "confidence_level" in fields
|
||||
assert "truth_status" in fields
|
||||
assert "generation_mode" in fields
|
||||
assert "may_be_used_as_evidence" in fields
|
||||
assert "reviewed_by" in fields
|
||||
assert "reviewed_at" in fields
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. TestLLMGenerationAudit
|
||||
# ===========================================================================
|
||||
|
||||
class TestLLMGenerationAudit:
|
||||
"""Test LLM generation audit trail."""
|
||||
|
||||
def test_create_audit_record(self):
|
||||
"""POST /compliance/llm-audit should create a record."""
|
||||
mock_record = MagicMock()
|
||||
mock_record.id = "audit-001"
|
||||
mock_record.tenant_id = None
|
||||
mock_record.entity_type = "document"
|
||||
mock_record.entity_id = None
|
||||
mock_record.generation_mode = "draft_assistance"
|
||||
mock_record.truth_status = EvidenceTruthStatusEnum.GENERATED
|
||||
mock_record.may_be_used_as_evidence = False
|
||||
mock_record.llm_model = "qwen2.5vl:32b"
|
||||
mock_record.llm_provider = "ollama"
|
||||
mock_record.prompt_hash = None
|
||||
mock_record.input_summary = "Test input"
|
||||
mock_record.output_summary = "Test output"
|
||||
mock_record.extra_metadata = {}
|
||||
mock_record.created_at = NOW
|
||||
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock(side_effect=lambda r: setattr(r, 'id', 'audit-001'))
|
||||
|
||||
# We need to patch the LLMGenerationAuditDB constructor
|
||||
with patch('compliance.api.llm_audit_routes.LLMGenerationAuditDB', return_value=mock_record):
|
||||
resp = client.post("/compliance/llm-audit", json={
|
||||
"entity_type": "document",
|
||||
"generation_mode": "draft_assistance",
|
||||
"truth_status": "generated",
|
||||
"may_be_used_as_evidence": False,
|
||||
"llm_model": "qwen2.5vl:32b",
|
||||
"llm_provider": "ollama",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["entity_type"] == "document"
|
||||
assert data["truth_status"] == "generated"
|
||||
assert data["may_be_used_as_evidence"] is False
|
||||
|
||||
def test_truth_status_always_generated_for_llm(self):
|
||||
"""LLM-generated content should always start with truth_status=generated."""
|
||||
from compliance.db.models import LLMGenerationAuditDB, EvidenceTruthStatusEnum
|
||||
audit = LLMGenerationAuditDB()
|
||||
# Default should be GENERATED
|
||||
assert audit.truth_status is None or audit.truth_status == EvidenceTruthStatusEnum.GENERATED
|
||||
|
||||
def test_may_be_used_as_evidence_defaults_false(self):
|
||||
"""Generated content should NOT be usable as evidence by default."""
|
||||
from compliance.db.models import LLMGenerationAuditDB
|
||||
audit = LLMGenerationAuditDB()
|
||||
assert audit.may_be_used_as_evidence is False or audit.may_be_used_as_evidence is None
|
||||
|
||||
def test_list_audit_records(self):
|
||||
"""GET /compliance/llm-audit should return records."""
|
||||
mock_query = MagicMock()
|
||||
mock_query.count.return_value = 0
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.all.return_value = []
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/compliance/llm-audit")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "records" in data
|
||||
assert "total" in data
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. TestEvidenceReview
|
||||
# ===========================================================================
|
||||
|
||||
class TestEvidenceReview:
|
||||
"""Test evidence review endpoint."""
|
||||
|
||||
def test_review_upgrades_confidence(self):
|
||||
"""PATCH /evidence/{id}/review should update confidence and set reviewer."""
|
||||
evidence = make_evidence({
|
||||
"confidence_level": EvidenceConfidenceEnum.E1,
|
||||
"truth_status": EvidenceTruthStatusEnum.UPLOADED,
|
||||
})
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
resp = client.patch(f"/evidence/{EVIDENCE_UUID}/review", json={
|
||||
"confidence_level": "E2",
|
||||
"truth_status": "validated_internal",
|
||||
"reviewed_by": "auditor@example.com",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify the evidence was updated
|
||||
assert evidence.confidence_level == EvidenceConfidenceEnum.E2
|
||||
assert evidence.truth_status == EvidenceTruthStatusEnum.VALIDATED_INTERNAL
|
||||
assert evidence.reviewed_by == "auditor@example.com"
|
||||
assert evidence.reviewed_at is not None
|
||||
|
||||
def test_review_nonexistent_evidence_returns_404(self):
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
resp = client.patch("/evidence/nonexistent-id/review", json={
|
||||
"reviewed_by": "someone",
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_review_invalid_confidence_returns_400(self):
|
||||
evidence = make_evidence()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
|
||||
resp = client.patch(f"/evidence/{EVIDENCE_UUID}/review", json={
|
||||
"confidence_level": "INVALID",
|
||||
"reviewed_by": "someone",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. TestControlUpdateIntegration
|
||||
# ===========================================================================
|
||||
|
||||
class TestControlUpdateIntegration:
|
||||
"""Test that ControlUpdate schema includes status_justification."""
|
||||
|
||||
def test_control_update_has_status_justification(self):
|
||||
from compliance.api.schemas import ControlUpdate
|
||||
fields = ControlUpdate.model_fields
|
||||
assert "status_justification" in fields
|
||||
|
||||
def test_control_response_has_status_justification(self):
|
||||
from compliance.api.schemas import ControlResponse
|
||||
fields = ControlResponse.model_fields
|
||||
assert "status_justification" in fields
|
||||
|
||||
def test_control_status_enum_has_in_progress(self):
|
||||
assert ControlStatusEnum.IN_PROGRESS.value == "in_progress"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9. TestEvidenceEnums
|
||||
# ===========================================================================
|
||||
|
||||
class TestEvidenceEnums:
|
||||
"""Test the new evidence enums."""
|
||||
|
||||
def test_confidence_enum_values(self):
|
||||
assert EvidenceConfidenceEnum.E0.value == "E0"
|
||||
assert EvidenceConfidenceEnum.E1.value == "E1"
|
||||
assert EvidenceConfidenceEnum.E2.value == "E2"
|
||||
assert EvidenceConfidenceEnum.E3.value == "E3"
|
||||
assert EvidenceConfidenceEnum.E4.value == "E4"
|
||||
|
||||
def test_truth_status_enum_values(self):
|
||||
assert EvidenceTruthStatusEnum.GENERATED.value == "generated"
|
||||
assert EvidenceTruthStatusEnum.UPLOADED.value == "uploaded"
|
||||
assert EvidenceTruthStatusEnum.OBSERVED.value == "observed"
|
||||
assert EvidenceTruthStatusEnum.VALIDATED_INTERNAL.value == "validated_internal"
|
||||
assert EvidenceTruthStatusEnum.REJECTED.value == "rejected"
|
||||
assert EvidenceTruthStatusEnum.PROVIDED_TO_AUDITOR.value == "provided_to_auditor"
|
||||
assert EvidenceTruthStatusEnum.ACCEPTED_BY_AUDITOR.value == "accepted_by_auditor"
|
||||
528
backend-compliance/tests/test_anti_fake_evidence_phase2.py
Normal file
528
backend-compliance/tests/test_anti_fake_evidence_phase2.py
Normal file
@@ -0,0 +1,528 @@
|
||||
"""Tests for Anti-Fake-Evidence Phase 2.
|
||||
|
||||
~35 tests covering:
|
||||
- Audit trail extension (evidence review/create logging)
|
||||
- Assertion engine (extraction, CRUD, verify, summary)
|
||||
- Four-Eyes review (domain check, first/second review, same-person reject)
|
||||
- UI badge data (response schema includes new fields)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.evidence_routes import (
|
||||
router as evidence_router,
|
||||
_requires_four_eyes,
|
||||
_classify_confidence,
|
||||
_classify_truth_status,
|
||||
)
|
||||
from compliance.api.assertion_routes import router as assertion_router
|
||||
from compliance.services.assertion_engine import extract_assertions, _classify_sentence
|
||||
from compliance.db.models import (
|
||||
EvidenceConfidenceEnum,
|
||||
EvidenceTruthStatusEnum,
|
||||
ControlStatusEnum,
|
||||
AssertionDB,
|
||||
)
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup with mocked DB dependency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(evidence_router)
|
||||
app.include_router(assertion_router)
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
EVIDENCE_UUID = "eeee0002-aaaa-bbbb-cccc-ffffffffffff"
|
||||
CONTROL_UUID = "cccc0002-aaaa-bbbb-cccc-dddddddddddd"
|
||||
ASSERTION_UUID = "aaaa0002-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
NOW = datetime(2026, 3, 23, 14, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_evidence(overrides=None):
|
||||
e = MagicMock()
|
||||
e.id = EVIDENCE_UUID
|
||||
e.control_id = CONTROL_UUID
|
||||
e.evidence_type = "test_results"
|
||||
e.title = "Phase 2 Test Evidence"
|
||||
e.description = "Testing four-eyes"
|
||||
e.artifact_url = "https://ci.example.com/artifact"
|
||||
e.artifact_path = None
|
||||
e.artifact_hash = "abc123"
|
||||
e.file_size_bytes = None
|
||||
e.mime_type = None
|
||||
e.status = MagicMock()
|
||||
e.status.value = "valid"
|
||||
e.uploaded_by = None
|
||||
e.source = "api"
|
||||
e.ci_job_id = None
|
||||
e.valid_from = NOW
|
||||
e.valid_until = NOW + timedelta(days=90)
|
||||
e.collected_at = NOW
|
||||
e.created_at = NOW
|
||||
e.confidence_level = EvidenceConfidenceEnum.E1
|
||||
e.truth_status = EvidenceTruthStatusEnum.UPLOADED
|
||||
e.generation_mode = None
|
||||
e.may_be_used_as_evidence = True
|
||||
e.reviewed_by = None
|
||||
e.reviewed_at = None
|
||||
# Phase 2 fields
|
||||
e.approval_status = "none"
|
||||
e.first_reviewer = None
|
||||
e.first_reviewed_at = None
|
||||
e.second_reviewer = None
|
||||
e.second_reviewed_at = None
|
||||
e.requires_four_eyes = False
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
setattr(e, k, v)
|
||||
return e
|
||||
|
||||
|
||||
def make_assertion(overrides=None):
|
||||
a = MagicMock()
|
||||
a.id = ASSERTION_UUID
|
||||
a.tenant_id = "tenant-001"
|
||||
a.entity_type = "control"
|
||||
a.entity_id = CONTROL_UUID
|
||||
a.sentence_text = "Test assertion sentence"
|
||||
a.sentence_index = 0
|
||||
a.assertion_type = "assertion"
|
||||
a.evidence_ids = []
|
||||
a.confidence = 0.0
|
||||
a.normative_tier = "pflicht"
|
||||
a.verified_by = None
|
||||
a.verified_at = None
|
||||
a.created_at = NOW
|
||||
a.updated_at = NOW
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
setattr(a, k, v)
|
||||
return a
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. TestAuditTrailExtension
|
||||
# ===========================================================================
|
||||
|
||||
class TestAuditTrailExtension:
|
||||
"""Test that evidence review and create log audit trail entries."""
|
||||
|
||||
def test_review_evidence_logs_audit_trail(self):
|
||||
evidence = make_evidence()
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"confidence_level": "E2", "reviewed_by": "auditor@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# db.add should be called for audit trail entries
|
||||
assert mock_db.add.called
|
||||
|
||||
def test_review_evidence_records_old_and_new_confidence(self):
|
||||
evidence = make_evidence({"confidence_level": EvidenceConfidenceEnum.E1})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"confidence_level": "E3", "reviewed_by": "reviewer@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_review_evidence_records_truth_status_change(self):
|
||||
evidence = make_evidence({"truth_status": EvidenceTruthStatusEnum.UPLOADED})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"truth_status": "validated_internal", "reviewed_by": "reviewer@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_review_nonexistent_evidence_returns_404(self):
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
"/evidence/nonexistent/review",
|
||||
json={"reviewed_by": "someone"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_reject_evidence_logs_audit_trail(self):
|
||||
evidence = make_evidence()
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/reject",
|
||||
json={"reviewed_by": "auditor@test.com", "rejection_reason": "Fake evidence"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["approval_status"] == "rejected"
|
||||
|
||||
def test_reject_nonexistent_evidence_returns_404(self):
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
"/evidence/nonexistent/reject",
|
||||
json={"reviewed_by": "someone"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_audit_trail_query_endpoint(self):
|
||||
mock_db.reset_mock()
|
||||
trail_entry = MagicMock()
|
||||
trail_entry.id = "trail-001"
|
||||
trail_entry.entity_type = "evidence"
|
||||
trail_entry.entity_id = EVIDENCE_UUID
|
||||
trail_entry.entity_name = "Test"
|
||||
trail_entry.action = "review"
|
||||
trail_entry.field_changed = "confidence_level"
|
||||
trail_entry.old_value = "E1"
|
||||
trail_entry.new_value = "E2"
|
||||
trail_entry.change_summary = None
|
||||
trail_entry.performed_by = "auditor"
|
||||
trail_entry.performed_at = NOW
|
||||
trail_entry.checksum = "abc"
|
||||
mock_db.query.return_value.filter.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [trail_entry]
|
||||
|
||||
resp = client.get(f"/audit-trail?entity_type=evidence&entity_id={EVIDENCE_UUID}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
def test_audit_trail_checksum_present(self):
|
||||
"""Audit trail entries should have a checksum for integrity."""
|
||||
from compliance.api.audit_trail_utils import create_signature
|
||||
sig = create_signature("evidence|123|review|user@test.com")
|
||||
assert len(sig) == 64 # SHA-256 hex digest
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. TestAssertionEngine
|
||||
# ===========================================================================
|
||||
|
||||
class TestAssertionEngine:
|
||||
"""Test assertion extraction and classification."""
|
||||
|
||||
def test_pflicht_sentence_classified_as_assertion(self):
|
||||
result = _classify_sentence("Die Organisation muss ein ISMS implementieren.")
|
||||
assert result == ("assertion", "pflicht")
|
||||
|
||||
def test_empfehlung_sentence_classified(self):
|
||||
result = _classify_sentence("Die Organisation sollte regelmäßige Audits durchführen.")
|
||||
assert result == ("assertion", "empfehlung")
|
||||
|
||||
def test_kann_sentence_classified(self):
|
||||
result = _classify_sentence("Optional kann ein externes Audit durchgeführt werden.")
|
||||
assert result == ("assertion", "kann")
|
||||
|
||||
def test_rationale_sentence_classified(self):
|
||||
result = _classify_sentence("Dies ist erforderlich, weil Datenverlust schwere Folgen hat.")
|
||||
assert result == ("rationale", None)
|
||||
|
||||
def test_fact_sentence_with_evidence_keyword(self):
|
||||
result = _classify_sentence("Das Zertifikat wurde am 15.03.2026 ausgestellt.")
|
||||
assert result == ("fact", None)
|
||||
|
||||
def test_extract_assertions_splits_sentences(self):
|
||||
text = "Die Organisation muss Daten schützen. Sie sollte regelmäßig prüfen."
|
||||
results = extract_assertions(text, "control", "ctrl-001")
|
||||
assert len(results) == 2
|
||||
assert results[0]["assertion_type"] == "assertion"
|
||||
assert results[0]["normative_tier"] == "pflicht"
|
||||
assert results[1]["normative_tier"] == "empfehlung"
|
||||
|
||||
def test_extract_assertions_empty_text(self):
|
||||
results = extract_assertions("", "control", "ctrl-001")
|
||||
assert results == []
|
||||
|
||||
def test_extract_assertions_single_sentence(self):
|
||||
results = extract_assertions("Der Betreiber muss ein Audit durchführen.", "control", "ctrl-001")
|
||||
assert len(results) == 1
|
||||
assert results[0]["normative_tier"] == "pflicht"
|
||||
|
||||
def test_mixed_text_with_rationale(self):
|
||||
text = "Die Organisation muss ein ISMS implementieren. Dies ist notwendig, weil Compliance gefordert ist."
|
||||
results = extract_assertions(text, "control", "ctrl-001")
|
||||
assert len(results) == 2
|
||||
types = [r["assertion_type"] for r in results]
|
||||
assert "assertion" in types
|
||||
assert "rationale" in types
|
||||
|
||||
def test_assertion_crud_create(self):
|
||||
mock_db.reset_mock()
|
||||
mock_db.refresh.return_value = None
|
||||
# Mock the added object to return proper values
|
||||
def side_effect_add(obj):
|
||||
obj.id = ASSERTION_UUID
|
||||
obj.created_at = NOW
|
||||
obj.updated_at = NOW
|
||||
obj.sentence_index = 0
|
||||
obj.confidence = 0.0
|
||||
mock_db.add.side_effect = side_effect_add
|
||||
|
||||
resp = client.post(
|
||||
"/assertions?tenant_id=tenant-001",
|
||||
json={
|
||||
"entity_type": "control",
|
||||
"entity_id": CONTROL_UUID,
|
||||
"sentence_text": "Die Organisation muss ein ISMS implementieren.",
|
||||
"assertion_type": "assertion",
|
||||
"normative_tier": "pflicht",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_assertion_verify_endpoint(self):
|
||||
a = make_assertion()
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = a
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.post(f"/assertions/{ASSERTION_UUID}/verify?verified_by=auditor@test.com")
|
||||
assert resp.status_code == 200
|
||||
assert a.assertion_type == "fact"
|
||||
assert a.verified_by == "auditor@test.com"
|
||||
|
||||
def test_assertion_summary(self):
|
||||
mock_db.reset_mock()
|
||||
a1 = make_assertion({"assertion_type": "assertion", "verified_by": None})
|
||||
a2 = make_assertion({"assertion_type": "fact", "verified_by": "user"})
|
||||
a3 = make_assertion({"assertion_type": "rationale", "verified_by": None})
|
||||
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [a1, a2, a3]
|
||||
# Direct .all() for no-filter case
|
||||
mock_db.query.return_value.all.return_value = [a1, a2, a3]
|
||||
|
||||
resp = client.get("/assertions/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_assertions"] == 3
|
||||
assert data["total_facts"] == 1
|
||||
assert data["total_rationale"] == 1
|
||||
assert data["unverified_count"] == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. TestFourEyesReview
|
||||
# ===========================================================================
|
||||
|
||||
class TestFourEyesReview:
|
||||
"""Test Four-Eyes review process."""
|
||||
|
||||
def test_gov_domain_requires_four_eyes(self):
|
||||
assert _requires_four_eyes("gov") is True
|
||||
|
||||
def test_priv_domain_requires_four_eyes(self):
|
||||
assert _requires_four_eyes("priv") is True
|
||||
|
||||
def test_ops_domain_does_not_require_four_eyes(self):
|
||||
assert _requires_four_eyes("ops") is False
|
||||
|
||||
def test_sdlc_domain_does_not_require_four_eyes(self):
|
||||
assert _requires_four_eyes("sdlc") is False
|
||||
|
||||
def test_first_review_sets_first_approved(self):
|
||||
evidence = make_evidence({
|
||||
"requires_four_eyes": True,
|
||||
"approval_status": "pending_first",
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"reviewed_by": "reviewer1@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert evidence.first_reviewer == "reviewer1@test.com"
|
||||
assert evidence.approval_status == "first_approved"
|
||||
|
||||
def test_second_review_different_person_approves(self):
|
||||
evidence = make_evidence({
|
||||
"requires_four_eyes": True,
|
||||
"approval_status": "first_approved",
|
||||
"first_reviewer": "reviewer1@test.com",
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"reviewed_by": "reviewer2@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert evidence.second_reviewer == "reviewer2@test.com"
|
||||
assert evidence.approval_status == "approved"
|
||||
|
||||
def test_same_person_second_review_rejected(self):
|
||||
evidence = make_evidence({
|
||||
"requires_four_eyes": True,
|
||||
"approval_status": "first_approved",
|
||||
"first_reviewer": "reviewer1@test.com",
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"reviewed_by": "reviewer1@test.com"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "different" in resp.json()["detail"].lower()
|
||||
|
||||
def test_already_approved_blocked(self):
|
||||
evidence = make_evidence({
|
||||
"requires_four_eyes": True,
|
||||
"approval_status": "approved",
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"reviewed_by": "reviewer3@test.com"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "already" in resp.json()["detail"].lower()
|
||||
|
||||
def test_rejected_evidence_cannot_be_reviewed(self):
|
||||
evidence = make_evidence({
|
||||
"requires_four_eyes": True,
|
||||
"approval_status": "rejected",
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"reviewed_by": "reviewer@test.com"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_reject_endpoint(self):
|
||||
evidence = make_evidence({"requires_four_eyes": True})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/reject",
|
||||
json={"reviewed_by": "auditor@test.com", "rejection_reason": "Not authentic"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert evidence.approval_status == "rejected"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. TestUIBadgeData
|
||||
# ===========================================================================
|
||||
|
||||
class TestUIBadgeData:
|
||||
"""Test that evidence response includes all Phase 2 fields."""
|
||||
|
||||
def test_evidence_response_includes_approval_status(self):
|
||||
evidence = make_evidence({
|
||||
"approval_status": "first_approved",
|
||||
"first_reviewer": "reviewer1@test.com",
|
||||
"first_reviewed_at": NOW,
|
||||
"requires_four_eyes": True,
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
mock_db.refresh.return_value = None
|
||||
|
||||
resp = client.patch(
|
||||
f"/evidence/{EVIDENCE_UUID}/review",
|
||||
json={"reviewed_by": "reviewer2@test.com"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "approval_status" in data
|
||||
assert "requires_four_eyes" in data
|
||||
assert data["requires_four_eyes"] is True
|
||||
|
||||
def test_evidence_response_includes_four_eyes_fields(self):
|
||||
evidence = make_evidence({
|
||||
"requires_four_eyes": True,
|
||||
"approval_status": "approved",
|
||||
"first_reviewer": "r1@test.com",
|
||||
"first_reviewed_at": NOW,
|
||||
"second_reviewer": "r2@test.com",
|
||||
"second_reviewed_at": NOW,
|
||||
})
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = evidence
|
||||
|
||||
# Use list endpoint
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [evidence]
|
||||
mock_db.query.return_value.all.return_value = [evidence]
|
||||
|
||||
# Direct test via _build_evidence_response
|
||||
from compliance.api.evidence_routes import _build_evidence_response
|
||||
resp = _build_evidence_response(evidence)
|
||||
assert resp.approval_status == "approved"
|
||||
assert resp.first_reviewer == "r1@test.com"
|
||||
assert resp.second_reviewer == "r2@test.com"
|
||||
assert resp.requires_four_eyes is True
|
||||
|
||||
def test_assertion_response_schema(self):
|
||||
a = make_assertion()
|
||||
mock_db.reset_mock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = a
|
||||
|
||||
resp = client.get(f"/assertions/{ASSERTION_UUID}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "assertion_type" in data
|
||||
assert "normative_tier" in data
|
||||
assert "evidence_ids" in data
|
||||
assert "verified_by" in data
|
||||
|
||||
def test_evidence_response_includes_confidence_and_truth(self):
|
||||
evidence = make_evidence({
|
||||
"confidence_level": EvidenceConfidenceEnum.E3,
|
||||
"truth_status": EvidenceTruthStatusEnum.OBSERVED,
|
||||
})
|
||||
from compliance.api.evidence_routes import _build_evidence_response
|
||||
resp = _build_evidence_response(evidence)
|
||||
assert resp.confidence_level == "E3"
|
||||
assert resp.truth_status == "observed"
|
||||
|
||||
def test_evidence_response_none_four_eyes_fields_default(self):
|
||||
evidence = make_evidence()
|
||||
from compliance.api.evidence_routes import _build_evidence_response
|
||||
resp = _build_evidence_response(evidence)
|
||||
assert resp.approval_status == "none"
|
||||
assert resp.requires_four_eyes is False
|
||||
assert resp.first_reviewer is None
|
||||
191
backend-compliance/tests/test_anti_fake_evidence_phase3.py
Normal file
191
backend-compliance/tests/test_anti_fake_evidence_phase3.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for Anti-Fake-Evidence Phase 3: Enforcement.
|
||||
|
||||
~8 tests covering:
|
||||
- Evidence distribution endpoint (confidence counts, four-eyes pending)
|
||||
- Dashboard multi-score presence
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.dashboard_routes import router as dashboard_router
|
||||
from compliance.db.models import EvidenceConfidenceEnum, EvidenceTruthStatusEnum
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup with mocked DB dependency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
NOW = datetime(2026, 3, 23, 14, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_evidence(confidence="E1", requires_four_eyes=False, approval_status="none"):
|
||||
e = MagicMock()
|
||||
e.confidence_level = MagicMock()
|
||||
e.confidence_level.value = confidence
|
||||
e.requires_four_eyes = requires_four_eyes
|
||||
e.approval_status = approval_status
|
||||
return e
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. TestEvidenceDistributionEndpoint
|
||||
# ===========================================================================
|
||||
|
||||
class TestEvidenceDistributionEndpoint:
|
||||
"""Test GET /dashboard/evidence-distribution endpoint."""
|
||||
|
||||
def _setup_evidence(self, evidence_list):
|
||||
"""Configure mock DB to return evidence list via EvidenceRepository."""
|
||||
mock_db.reset_mock()
|
||||
# EvidenceRepository(db).get_all() internally does db.query(...).all()
|
||||
# We patch the EvidenceRepository class to return our list
|
||||
return evidence_list
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
def test_empty_db_returns_zero_counts(self, mock_repo_cls):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_all.return_value = []
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
resp = client.get("/dashboard/evidence-distribution")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["four_eyes_pending"] == 0
|
||||
assert data["by_confidence"] == {"E0": 0, "E1": 0, "E2": 0, "E3": 0, "E4": 0}
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
def test_counts_by_confidence_level(self, mock_repo_cls):
|
||||
evidence = [
|
||||
make_evidence("E0"),
|
||||
make_evidence("E1"),
|
||||
make_evidence("E1"),
|
||||
make_evidence("E2"),
|
||||
make_evidence("E3"),
|
||||
make_evidence("E3"),
|
||||
make_evidence("E3"),
|
||||
make_evidence("E4"),
|
||||
]
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_all.return_value = evidence
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
resp = client.get("/dashboard/evidence-distribution")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 8
|
||||
assert data["by_confidence"]["E0"] == 1
|
||||
assert data["by_confidence"]["E1"] == 2
|
||||
assert data["by_confidence"]["E2"] == 1
|
||||
assert data["by_confidence"]["E3"] == 3
|
||||
assert data["by_confidence"]["E4"] == 1
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
def test_four_eyes_pending_count(self, mock_repo_cls):
|
||||
evidence = [
|
||||
make_evidence("E1", requires_four_eyes=True, approval_status="pending_first"),
|
||||
make_evidence("E2", requires_four_eyes=True, approval_status="first_approved"),
|
||||
make_evidence("E2", requires_four_eyes=True, approval_status="approved"),
|
||||
make_evidence("E1", requires_four_eyes=True, approval_status="rejected"),
|
||||
make_evidence("E1", requires_four_eyes=False, approval_status="none"),
|
||||
]
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_all.return_value = evidence
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
resp = client.get("/dashboard/evidence-distribution")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# pending_first and first_approved are pending; approved and rejected are not
|
||||
assert data["four_eyes_pending"] == 2
|
||||
assert data["total"] == 5
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
def test_null_confidence_defaults_to_e1(self, mock_repo_cls):
|
||||
e = MagicMock()
|
||||
e.confidence_level = None
|
||||
e.requires_four_eyes = False
|
||||
e.approval_status = "none"
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_all.return_value = [e]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
resp = client.get("/dashboard/evidence-distribution")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["by_confidence"]["E1"] == 1
|
||||
assert data["total"] == 1
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
def test_all_four_eyes_approved_zero_pending(self, mock_repo_cls):
|
||||
evidence = [
|
||||
make_evidence("E2", requires_four_eyes=True, approval_status="approved"),
|
||||
make_evidence("E3", requires_four_eyes=True, approval_status="approved"),
|
||||
]
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_all.return_value = evidence
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
resp = client.get("/dashboard/evidence-distribution")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["four_eyes_pending"] == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. TestDashboardMultiScore
|
||||
# ===========================================================================
|
||||
|
||||
class TestDashboardMultiScore:
|
||||
"""Test that dashboard response includes multi_score."""
|
||||
|
||||
def test_dashboard_response_schema_includes_multi_score(self):
|
||||
"""DashboardResponse schema must include the multi_score field."""
|
||||
from compliance.api.schemas import DashboardResponse
|
||||
fields = DashboardResponse.model_fields
|
||||
assert "multi_score" in fields, "DashboardResponse must have multi_score field"
|
||||
|
||||
def test_multi_score_schema_has_required_fields(self):
|
||||
"""MultiDimensionalScore schema should have all 7 fields."""
|
||||
from compliance.api.schemas import MultiDimensionalScore
|
||||
fields = MultiDimensionalScore.model_fields
|
||||
required = [
|
||||
"requirement_coverage",
|
||||
"evidence_strength",
|
||||
"validation_quality",
|
||||
"evidence_freshness",
|
||||
"control_effectiveness",
|
||||
"overall_readiness",
|
||||
"hard_blocks",
|
||||
]
|
||||
for field in required:
|
||||
assert field in fields, f"Missing field: {field}"
|
||||
|
||||
def test_multi_score_default_values(self):
|
||||
"""MultiDimensionalScore defaults should be sensible."""
|
||||
from compliance.api.schemas import MultiDimensionalScore
|
||||
score = MultiDimensionalScore()
|
||||
assert score.overall_readiness == 0.0
|
||||
assert score.hard_blocks == []
|
||||
assert score.requirement_coverage == 0.0
|
||||
277
backend-compliance/tests/test_anti_fake_evidence_phase4.py
Normal file
277
backend-compliance/tests/test_anti_fake_evidence_phase4.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Tests for Anti-Fake-Evidence Phase 4a: Traceability Matrix.
|
||||
|
||||
6 tests covering:
|
||||
- Empty DB returns empty controls + zero summary
|
||||
- Nested structure: Control → Evidence → Assertions
|
||||
- Assertions appear under correct evidence
|
||||
- Coverage flags computed correctly
|
||||
- Control without evidence has correct coverage
|
||||
- Summary counts match
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.api.dashboard_routes import router as dashboard_router
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App setup with mocked DB dependency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
|
||||
def override_get_db():
|
||||
yield mock_db
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_control(id="c1", control_id="CTRL-001", title="Test Control", status="pass", domain="gov"):
|
||||
ctrl = MagicMock()
|
||||
ctrl.id = id
|
||||
ctrl.control_id = control_id
|
||||
ctrl.title = title
|
||||
ctrl.status = MagicMock()
|
||||
ctrl.status.value = status
|
||||
ctrl.domain = MagicMock()
|
||||
ctrl.domain.value = domain
|
||||
return ctrl
|
||||
|
||||
|
||||
def make_evidence(id="e1", control_id="c1", title="Evidence 1", evidence_type="scan_report",
|
||||
confidence="E2", status="valid"):
|
||||
e = MagicMock()
|
||||
e.id = id
|
||||
e.control_id = control_id
|
||||
e.title = title
|
||||
e.evidence_type = evidence_type
|
||||
e.confidence_level = MagicMock()
|
||||
e.confidence_level.value = confidence
|
||||
e.status = MagicMock()
|
||||
e.status.value = status
|
||||
return e
|
||||
|
||||
|
||||
def make_assertion(id="a1", entity_id="e1", sentence_text="System encrypts data at rest.",
|
||||
assertion_type="assertion", confidence=0.85, verified_by=None):
|
||||
a = MagicMock()
|
||||
a.id = id
|
||||
a.entity_id = entity_id
|
||||
a.sentence_text = sentence_text
|
||||
a.assertion_type = assertion_type
|
||||
a.confidence = confidence
|
||||
a.verified_by = verified_by
|
||||
return a
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestTraceabilityMatrix:
|
||||
"""Test GET /dashboard/traceability-matrix endpoint."""
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
@patch("compliance.api.dashboard_routes.ControlRepository")
|
||||
def test_empty_db_returns_empty_matrix(self, mock_ctrl_cls, mock_ev_cls):
|
||||
"""Empty DB should return zero controls and zero summary counts."""
|
||||
mock_ctrl = MagicMock()
|
||||
mock_ctrl.get_all.return_value = []
|
||||
mock_ctrl_cls.return_value = mock_ctrl
|
||||
|
||||
mock_ev = MagicMock()
|
||||
mock_ev.get_all.return_value = []
|
||||
mock_ev_cls.return_value = mock_ev
|
||||
|
||||
# Mock db.query(AssertionDB).filter(...).all()
|
||||
mock_db.reset_mock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.all.return_value = []
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/dashboard/traceability-matrix")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["controls"] == []
|
||||
assert data["summary"]["total_controls"] == 0
|
||||
assert data["summary"]["covered_controls"] == 0
|
||||
assert data["summary"]["fully_verified"] == 0
|
||||
assert data["summary"]["uncovered_controls"] == 0
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
@patch("compliance.api.dashboard_routes.ControlRepository")
|
||||
def test_nested_structure(self, mock_ctrl_cls, mock_ev_cls):
|
||||
"""Control with evidence and assertions should return nested structure."""
|
||||
ctrl = make_control(id="c1", control_id="PRIV-001", title="Privacy Control")
|
||||
ev = make_evidence(id="e1", control_id="c1", confidence="E3")
|
||||
assertion = make_assertion(id="a1", entity_id="e1", verified_by="auditor@example.com")
|
||||
|
||||
mock_ctrl = MagicMock()
|
||||
mock_ctrl.get_all.return_value = [ctrl]
|
||||
mock_ctrl_cls.return_value = mock_ctrl
|
||||
|
||||
mock_ev = MagicMock()
|
||||
mock_ev.get_all.return_value = [ev]
|
||||
mock_ev_cls.return_value = mock_ev
|
||||
|
||||
mock_db.reset_mock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.all.return_value = [assertion]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/dashboard/traceability-matrix")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert len(data["controls"]) == 1
|
||||
c = data["controls"][0]
|
||||
assert c["control_id"] == "PRIV-001"
|
||||
assert len(c["evidence"]) == 1
|
||||
assert c["evidence"][0]["confidence_level"] == "E3"
|
||||
assert len(c["evidence"][0]["assertions"]) == 1
|
||||
assert c["evidence"][0]["assertions"][0]["verified"] is True
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
@patch("compliance.api.dashboard_routes.ControlRepository")
|
||||
def test_assertions_grouped_under_correct_evidence(self, mock_ctrl_cls, mock_ev_cls):
|
||||
"""Assertions should only appear under the evidence they reference."""
|
||||
ctrl = make_control(id="c1")
|
||||
ev1 = make_evidence(id="e1", control_id="c1", title="Evidence A")
|
||||
ev2 = make_evidence(id="e2", control_id="c1", title="Evidence B")
|
||||
a1 = make_assertion(id="a1", entity_id="e1", sentence_text="Assertion for E1")
|
||||
a2 = make_assertion(id="a2", entity_id="e2", sentence_text="Assertion for E2")
|
||||
a3 = make_assertion(id="a3", entity_id="e2", sentence_text="Second assertion for E2")
|
||||
|
||||
mock_ctrl = MagicMock()
|
||||
mock_ctrl.get_all.return_value = [ctrl]
|
||||
mock_ctrl_cls.return_value = mock_ctrl
|
||||
|
||||
mock_ev = MagicMock()
|
||||
mock_ev.get_all.return_value = [ev1, ev2]
|
||||
mock_ev_cls.return_value = mock_ev
|
||||
|
||||
mock_db.reset_mock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.all.return_value = [a1, a2, a3]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/dashboard/traceability-matrix")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
c = data["controls"][0]
|
||||
ev1_data = next(e for e in c["evidence"] if e["id"] == "e1")
|
||||
ev2_data = next(e for e in c["evidence"] if e["id"] == "e2")
|
||||
assert len(ev1_data["assertions"]) == 1
|
||||
assert len(ev2_data["assertions"]) == 2
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
@patch("compliance.api.dashboard_routes.ControlRepository")
|
||||
def test_coverage_flags_correct(self, mock_ctrl_cls, mock_ev_cls):
|
||||
"""Coverage flags should reflect evidence, assertions, and verification state."""
|
||||
ctrl = make_control(id="c1")
|
||||
ev = make_evidence(id="e1", control_id="c1", confidence="E2")
|
||||
# One verified, one not
|
||||
a1 = make_assertion(id="a1", entity_id="e1", verified_by="alice")
|
||||
a2 = make_assertion(id="a2", entity_id="e1", verified_by=None)
|
||||
|
||||
mock_ctrl = MagicMock()
|
||||
mock_ctrl.get_all.return_value = [ctrl]
|
||||
mock_ctrl_cls.return_value = mock_ctrl
|
||||
|
||||
mock_ev = MagicMock()
|
||||
mock_ev.get_all.return_value = [ev]
|
||||
mock_ev_cls.return_value = mock_ev
|
||||
|
||||
mock_db.reset_mock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.all.return_value = [a1, a2]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/dashboard/traceability-matrix")
|
||||
assert resp.status_code == 200
|
||||
|
||||
cov = resp.json()["controls"][0]["coverage"]
|
||||
assert cov["has_evidence"] is True
|
||||
assert cov["has_assertions"] is True
|
||||
assert cov["all_assertions_verified"] is False # a2 not verified
|
||||
assert cov["min_confidence_level"] == "E2"
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
@patch("compliance.api.dashboard_routes.ControlRepository")
|
||||
def test_coverage_without_evidence(self, mock_ctrl_cls, mock_ev_cls):
|
||||
"""Control with no evidence should have all coverage flags False/None."""
|
||||
ctrl = make_control(id="c1")
|
||||
|
||||
mock_ctrl = MagicMock()
|
||||
mock_ctrl.get_all.return_value = [ctrl]
|
||||
mock_ctrl_cls.return_value = mock_ctrl
|
||||
|
||||
mock_ev = MagicMock()
|
||||
mock_ev.get_all.return_value = []
|
||||
mock_ev_cls.return_value = mock_ev
|
||||
|
||||
mock_db.reset_mock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.all.return_value = []
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/dashboard/traceability-matrix")
|
||||
assert resp.status_code == 200
|
||||
|
||||
cov = resp.json()["controls"][0]["coverage"]
|
||||
assert cov["has_evidence"] is False
|
||||
assert cov["has_assertions"] is False
|
||||
assert cov["all_assertions_verified"] is False
|
||||
assert cov["min_confidence_level"] is None
|
||||
|
||||
@patch("compliance.api.dashboard_routes.EvidenceRepository")
|
||||
@patch("compliance.api.dashboard_routes.ControlRepository")
|
||||
def test_summary_counts(self, mock_ctrl_cls, mock_ev_cls):
|
||||
"""Summary should count total, covered, fully verified, and uncovered controls."""
|
||||
# c1: has evidence + verified assertions → fully verified
|
||||
# c2: has evidence but no assertions → covered, not fully verified
|
||||
# c3: no evidence → uncovered
|
||||
c1 = make_control(id="c1", control_id="C-001")
|
||||
c2 = make_control(id="c2", control_id="C-002")
|
||||
c3 = make_control(id="c3", control_id="C-003")
|
||||
|
||||
ev1 = make_evidence(id="e1", control_id="c1", confidence="E3")
|
||||
ev2 = make_evidence(id="e2", control_id="c2", confidence="E1")
|
||||
|
||||
a1 = make_assertion(id="a1", entity_id="e1", verified_by="auditor")
|
||||
|
||||
mock_ctrl = MagicMock()
|
||||
mock_ctrl.get_all.return_value = [c1, c2, c3]
|
||||
mock_ctrl_cls.return_value = mock_ctrl
|
||||
|
||||
mock_ev = MagicMock()
|
||||
mock_ev.get_all.return_value = [ev1, ev2]
|
||||
mock_ev_cls.return_value = mock_ev
|
||||
|
||||
mock_db.reset_mock()
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.all.return_value = [a1]
|
||||
mock_db.query.return_value = mock_query
|
||||
|
||||
resp = client.get("/dashboard/traceability-matrix")
|
||||
assert resp.status_code == 200
|
||||
|
||||
summary = resp.json()["summary"]
|
||||
assert summary["total_controls"] == 3
|
||||
assert summary["covered_controls"] == 2
|
||||
assert summary["fully_verified"] == 1
|
||||
assert summary["uncovered_controls"] == 1
|
||||
@@ -61,6 +61,7 @@ def make_control(overrides=None):
|
||||
c.status = MagicMock()
|
||||
c.status.value = "planned"
|
||||
c.status_notes = None
|
||||
c.status_justification = None
|
||||
c.last_reviewed_at = None
|
||||
c.next_review_at = None
|
||||
c.created_at = NOW
|
||||
@@ -249,15 +250,15 @@ class TestUpdateControl:
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_status_with_valid_enum(self):
|
||||
"""Status must be a valid ControlStatusEnum value."""
|
||||
"""Status must be a valid ControlStatusEnum value (planned → in_progress is always allowed)."""
|
||||
updated = make_control()
|
||||
updated.status.value = "pass"
|
||||
updated.status.value = "in_progress"
|
||||
with patch("compliance.api.routes.ControlRepository") as MockRepo:
|
||||
MockRepo.return_value.get_by_control_id.return_value = make_control()
|
||||
MockRepo.return_value.update.return_value = updated
|
||||
response = client.put(
|
||||
"/compliance/controls/GOV-001",
|
||||
json={"status": "pass"},
|
||||
json={"status": "in_progress"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ def make_evidence(overrides=None):
|
||||
e.valid_until = None
|
||||
e.collected_at = NOW
|
||||
e.created_at = NOW
|
||||
# Anti-Fake-Evidence fields
|
||||
e.confidence_level = MagicMock()
|
||||
e.confidence_level.value = "E1"
|
||||
e.truth_status = MagicMock()
|
||||
e.truth_status.value = "uploaded"
|
||||
e.generation_mode = None
|
||||
e.may_be_used_as_evidence = True
|
||||
e.reviewed_by = None
|
||||
e.reviewed_at = None
|
||||
# Phase 2 fields
|
||||
e.approval_status = "none"
|
||||
e.first_reviewer = None
|
||||
e.first_reviewed_at = None
|
||||
e.second_reviewer = None
|
||||
e.second_reviewed_at = None
|
||||
e.requires_four_eyes = False
|
||||
if overrides:
|
||||
for k, v in overrides.items():
|
||||
setattr(e, k, v)
|
||||
|
||||
460
docs-src/services/sdk-modules/anti-fake-evidence.md
Normal file
460
docs-src/services/sdk-modules/anti-fake-evidence.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# Anti-Fake-Evidence Architektur
|
||||
|
||||
**Status:** Phase 2 (aktiv seit 2026-03-23)
|
||||
**Prefix:** CP-AFE
|
||||
**Motivation:** Delve-Vorfall (Maerz 2026) — Compliance-Theater verhindern
|
||||
|
||||
## Motivation
|
||||
|
||||
Der Delve-Vorfall zeigte, wie Compliance-Automation zur Haftungsfalle wird:
|
||||
|
||||
- LLM-generierte Inhalte wurden als echte Nachweise behandelt
|
||||
- Controls ohne Evidence standen auf "pass"
|
||||
- 100%-Compliance-Claims ohne Validierung
|
||||
|
||||
Die Anti-Fake-Evidence Architektur implementiert 6 Guardrails, die sicherstellen, dass nur **nachgewiesene** Compliance als solche dargestellt wird.
|
||||
|
||||
---
|
||||
|
||||
## Evidence Confidence Levels (E0–E4)
|
||||
|
||||
| Level | Bezeichnung | Beschreibung | Beispiel |
|
||||
|-------|-------------|--------------|----------|
|
||||
| **E0** | Generated | LLM-Output, Platzhalter | KI-generierter Nachweis-Entwurf |
|
||||
| **E1** | Uploaded | Manuell hochgeladen, ungeprüft | PDF ohne Reviewer |
|
||||
| **E2** | Reviewed | Intern geprüft, Hash verifiziert | Dokument von Compliance-Beauftragtem bestätigt |
|
||||
| **E3** | Observed | System-beobachtet (CI/CD, API) | Automatischer SAST-Report mit SHA-256 |
|
||||
| **E4** | Auditor-validated | Extern validiert | Wirtschaftsprüfer hat akzeptiert |
|
||||
|
||||
### Auto-Klassifikation
|
||||
|
||||
| Source | Confidence | Truth Status |
|
||||
|--------|-----------|--------------|
|
||||
| `ci_pipeline` | E3 | observed |
|
||||
| `api` (mit Hash) | E3 | observed |
|
||||
| `manual` / `upload` | E1 | uploaded |
|
||||
| `generated` | E0 | generated |
|
||||
|
||||
---
|
||||
|
||||
## Evidence Truth-Status Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> generated : LLM erzeugt
|
||||
[*] --> uploaded : Manuell hochgeladen
|
||||
[*] --> observed : CI/CD Pipeline
|
||||
|
||||
generated --> rejected : Review abgelehnt
|
||||
uploaded --> validated_internal : Intern geprueft
|
||||
uploaded --> rejected : Review abgelehnt
|
||||
observed --> validated_internal : Intern bestaetigt
|
||||
|
||||
validated_internal --> provided_to_auditor : An Auditor uebergeben
|
||||
provided_to_auditor --> accepted_by_auditor : Auditor akzeptiert
|
||||
provided_to_auditor --> rejected : Auditor lehnt ab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Control Status-Transition State Machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
planned --> in_progress : immer erlaubt
|
||||
in_progress --> pass : Evidence >= E2 + truth_status valid
|
||||
in_progress --> partial : min 1 Evidence (beliebig)
|
||||
in_progress --> fail : immer erlaubt
|
||||
pass --> fail : Degradation (immer)
|
||||
partial --> pass : Evidence >= E2 + truth_status valid
|
||||
|
||||
note right of pass
|
||||
Voraussetzung: min 1 Evidence
|
||||
mit confidence >= E2 UND
|
||||
truth_status in (uploaded,
|
||||
observed, validated_internal,
|
||||
accepted_by_auditor)
|
||||
end note
|
||||
```
|
||||
|
||||
### Transition-Regeln
|
||||
|
||||
| Von | Nach | Voraussetzung |
|
||||
|-----|------|---------------|
|
||||
| planned | in_progress | keine |
|
||||
| in_progress | pass | min 1 Evidence mit confidence >= E2, truth_status valide |
|
||||
| in_progress | partial | min 1 Evidence (beliebig) |
|
||||
| in_progress | fail | immer erlaubt |
|
||||
| pass | fail | immer erlaubt (Degradation) |
|
||||
| * | n/a | erfordert `status_justification` |
|
||||
| * | planned | immer erlaubt (Reset) |
|
||||
|
||||
Bei Verstoß: **HTTP 409 Conflict** mit Liste der Violations.
|
||||
|
||||
---
|
||||
|
||||
## LLM Truth-Labels
|
||||
|
||||
Jeder LLM-generierte Inhalt wird mit einem Truth-Label versehen:
|
||||
|
||||
```json
|
||||
{
|
||||
"generation_mode": "draft_assistance",
|
||||
"truth_status": "generated",
|
||||
"may_be_used_as_evidence": false,
|
||||
"generated_by": "system"
|
||||
}
|
||||
```
|
||||
|
||||
### Audit-Trail
|
||||
|
||||
Tabelle `compliance_llm_generation_audit`:
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|--------------|
|
||||
| entity_type | VARCHAR(50) | 'evidence', 'control', 'document' |
|
||||
| entity_id | VARCHAR(36) | FK zur generierten Entitaet |
|
||||
| generation_mode | VARCHAR(100) | 'draft_assistance', 'auto_generation' |
|
||||
| truth_status | ENUM | generated, uploaded, ... |
|
||||
| may_be_used_as_evidence | BOOLEAN | Default: FALSE |
|
||||
| llm_model | VARCHAR(100) | z.B. 'qwen2.5vl:32b' |
|
||||
| llm_provider | VARCHAR(50) | 'ollama', 'anthropic' |
|
||||
| prompt_hash | VARCHAR(64) | SHA-256 des Prompts |
|
||||
|
||||
---
|
||||
|
||||
## Multi-dimensionaler Compliance-Score
|
||||
|
||||
Statt einer einzelnen Prozentzahl zeigt der Score 6 Dimensionen:
|
||||
|
||||
| Dimension | Gewicht | Beschreibung |
|
||||
|-----------|---------|--------------|
|
||||
| requirement_coverage | 20% | % Requirements mit verlinktem Control |
|
||||
| evidence_strength | 25% | Gewichteter Durchschnitt der Evidence-Confidence |
|
||||
| validation_quality | 20% | % Evidence mit truth_status >= validated_internal |
|
||||
| evidence_freshness | 10% | % Evidence nicht expired + reviewed < 90 Tage |
|
||||
| control_effectiveness | 25% | Bestehende Formel (pass + partial*0.5) |
|
||||
| **overall_readiness** | — | Gewichteter Composite der 5 Dimensionen |
|
||||
|
||||
### Hard Blocks
|
||||
|
||||
Zusaetzlich werden **Sperrgründe** angezeigt, die eine Audit-Readiness verhindern:
|
||||
|
||||
- Controls auf 'pass' ohne jegliche Evidence
|
||||
- Controls auf 'pass' mit nur E0/E1-Evidence (keine Validierung)
|
||||
|
||||
---
|
||||
|
||||
## Verbotene Formulierungen
|
||||
|
||||
Der Drafting-Engine Validator prueft auf Formulierungen, die **ohne ausreichenden Nachweis** nicht verwendet werden duerfen:
|
||||
|
||||
| Verboten | Sicher stattdessen |
|
||||
|----------|--------------------|
|
||||
| "ist compliant" | "soll compliant sein" |
|
||||
| "erfuellt vollstaendig" | "soll vollstaendig erfuellt werden" |
|
||||
| "wurde geprueft" | "soll geprueft werden" |
|
||||
| "wurde umgesetzt" | "ist zur Umsetzung vorgesehen" |
|
||||
| "ist auditiert" | "soll auditiert werden" |
|
||||
| "vollstaendig implementiert" | "Implementierung ist vorgesehen" |
|
||||
| "nachweislich konform" | "Konformitaet ist nachzuweisen" |
|
||||
|
||||
**Erlaubt nur wenn:** control_status = pass AND confidence >= E2 AND truth_status in (validated_internal, accepted_by_auditor).
|
||||
|
||||
---
|
||||
|
||||
## API-Aenderungen
|
||||
|
||||
### Neue Endpoints
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| PATCH | `/evidence/{id}/review` | Evidence reviewen (Confidence upgraden) |
|
||||
| POST | `/llm-audit` | LLM-Generierungs-Audit erstellen |
|
||||
| GET | `/llm-audit` | LLM-Audit-Eintraege auflisten |
|
||||
|
||||
### Erweiterte Responses
|
||||
|
||||
**EvidenceResponse** — 6 neue Felder:
|
||||
|
||||
```json
|
||||
{
|
||||
"confidence_level": "E3",
|
||||
"truth_status": "observed",
|
||||
"generation_mode": null,
|
||||
"may_be_used_as_evidence": true,
|
||||
"reviewed_by": null,
|
||||
"reviewed_at": null
|
||||
}
|
||||
```
|
||||
|
||||
**DashboardResponse** — neues Feld `multi_score`:
|
||||
|
||||
```json
|
||||
{
|
||||
"multi_score": {
|
||||
"requirement_coverage": 85.0,
|
||||
"evidence_strength": 60.0,
|
||||
"validation_quality": 40.0,
|
||||
"evidence_freshness": 90.0,
|
||||
"control_effectiveness": 70.0,
|
||||
"overall_readiness": 65.0,
|
||||
"hard_blocks": ["3 Controls auf 'pass' haben nur E0/E1-Evidence"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**ControlResponse** — neues Feld `status_justification`.
|
||||
|
||||
**ControlUpdate** — neues Feld `status_justification` (Pflicht fuer n/a-Transitions).
|
||||
|
||||
### Status-Transition Fehler (409)
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": {
|
||||
"error": "Status transition not allowed",
|
||||
"current_status": "in_progress",
|
||||
"requested_status": "pass",
|
||||
"violations": [
|
||||
"Transition to 'pass' requires at least 1 evidence with confidence >= E2..."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
### Phase 1
|
||||
**Datei:** `backend-compliance/migrations/076_anti_fake_evidence.sql`
|
||||
|
||||
- Neue ENUM-Typen: `evidence_confidence_level`, `evidence_truth_status`
|
||||
- 6 neue Spalten auf `compliance_evidence`
|
||||
- `in_progress` Wert fuer `controlstatusenum`
|
||||
- `status_justification` auf `compliance_controls`
|
||||
- Neue Tabelle `compliance_llm_generation_audit`
|
||||
- Backfill bestehender Evidence nach Source
|
||||
- Indizes auf neue Spalten
|
||||
|
||||
### Phase 2
|
||||
**Datei:** `backend-compliance/migrations/077_anti_fake_evidence_phase2.sql`
|
||||
|
||||
- Neue Tabelle `compliance_assertions` (Assertion Engine)
|
||||
- 6 neue Spalten auf `compliance_evidence` (Four-Eyes: approval_status, first_reviewer, etc.)
|
||||
- Performance-Index auf `compliance_audit_trail (entity_type, action, performed_at)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: UI-Badges
|
||||
|
||||
Badges werden auf Evidence-Cards angezeigt und zeigen den Vertrauensstatus auf einen Blick:
|
||||
|
||||
| Badge | Farben | Anzeige |
|
||||
|-------|--------|---------|
|
||||
| **ConfidenceLevelBadge** | E0=rot, E1=gelb, E2=blau, E3=gruen, E4=emerald | Immer |
|
||||
| **TruthStatusBadge** | generated=violet, uploaded=grau, observed=blau, validated=gruen, rejected=rot | Immer |
|
||||
| **GenerationModeBadge** | violet + Sparkles-Icon | Wenn LLM-generiert |
|
||||
| **ApprovalStatusBadge** | pending=gelb, first_approved=blau, approved=gruen, rejected=rot | Nur bei Four-Eyes |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Assertion Engine
|
||||
|
||||
Die Assertion Engine trennt **Behauptungen** von **Fakten** in Compliance-Texten.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Text[Freitext] --> Split[Satz-Splitting]
|
||||
Split --> Classify{Normativ?}
|
||||
Classify -->|Pflicht| A[Assertion pflicht]
|
||||
Classify -->|Empfehlung| B[Assertion empfehlung]
|
||||
Classify -->|Kann| C[Assertion kann]
|
||||
Classify -->|Begruendung| D[Rationale]
|
||||
Classify -->|Evidence-Keywords| E[Fact tentativ]
|
||||
E --> Verify[Manuell verifizieren]
|
||||
Verify --> Fact[Verified Fact]
|
||||
```
|
||||
|
||||
### Assertion-Typen
|
||||
|
||||
| Typ | Bedeutung | Beispiel |
|
||||
|-----|-----------|----------|
|
||||
| **assertion** | Normative Aussage (unbewiesen) | "Die Organisation muss ein ISMS implementieren" |
|
||||
| **fact** | Verifizierte Tatsache | "ISO-Zertifikat Nr. 12345 liegt vor" |
|
||||
| **rationale** | Begruendung | "Dies ist notwendig, weil..." |
|
||||
|
||||
### Normative Tiers
|
||||
|
||||
| Tier | Signal-Woerter |
|
||||
|------|---------------|
|
||||
| **pflicht** | muss, hat sicherzustellen, ist verpflichtet, shall, must, required |
|
||||
| **empfehlung** | soll, sollte, gewaehrleisten, should, ensure |
|
||||
| **kann** | kann, darf, may, optional |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Four-Eyes-Prinzip
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending_first : Evidence erstellt (Gov/Priv Domain)
|
||||
pending_first --> first_approved : 1. Reviewer OK
|
||||
first_approved --> approved : 2. Reviewer OK (andere Person!)
|
||||
first_approved --> rejected : 2. Reviewer lehnt ab
|
||||
pending_first --> rejected : 1. Reviewer lehnt ab
|
||||
|
||||
note right of first_approved
|
||||
Zweiter Reviewer MUSS
|
||||
eine andere Person sein
|
||||
als der erste Reviewer
|
||||
end note
|
||||
```
|
||||
|
||||
### Domains mit Four-Eyes-Pflicht
|
||||
|
||||
| Domain | Four-Eyes? | Begruendung |
|
||||
|--------|-----------|-------------|
|
||||
| `gov` | Ja | Governance-Controls sind audit-kritisch |
|
||||
| `priv` | Ja | Datenschutz erfordert unabhaengige Pruefung |
|
||||
| `ops`, `sdlc`, `ai`, ... | Nein | Operationale Controls mit Single-Review |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Audit-Trail-Erweiterung
|
||||
|
||||
Neue Audit-Trail-Eintraege:
|
||||
|
||||
| Entity | Action | Wann |
|
||||
|--------|--------|------|
|
||||
| evidence | create | Bei Evidence-Erstellung |
|
||||
| evidence | review | Bei Confidence/Truth-Status-Aenderung |
|
||||
| evidence | reject | Bei Evidence-Ablehnung |
|
||||
| control | status_change | Bei Control-Status-Aenderung |
|
||||
|
||||
Jeder Eintrag enthaelt `old_value`, `new_value` und einen SHA-256 `checksum`.
|
||||
|
||||
Neuer Query-Endpoint: `GET /audit-trail?entity_type=evidence&entity_id={id}`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Neue API-Endpoints
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| PATCH | `/evidence/{id}/reject` | Evidence ablehnen |
|
||||
| GET | `/audit-trail` | Audit-Trail abfragen (Filter: entity_type, entity_id, action) |
|
||||
| POST | `/assertions` | Assertion manuell erstellen |
|
||||
| GET | `/assertions` | Assertions auflisten (Filter: entity_type, entity_id, assertion_type) |
|
||||
| GET | `/assertions/{id}` | Assertion Detail |
|
||||
| PUT | `/assertions/{id}` | Assertion aktualisieren |
|
||||
| POST | `/assertions/{id}/verify` | Als Fakt markieren |
|
||||
| POST | `/assertions/extract` | Automatische Extraktion aus Freitext |
|
||||
| GET | `/assertions/summary` | Stats (total, facts, rationale, unverified) |
|
||||
|
||||
### Erweiterte EvidenceResponse (Phase 2)
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_status": "first_approved",
|
||||
"first_reviewer": "reviewer1@example.com",
|
||||
"first_reviewed_at": "2026-03-23T14:00:00Z",
|
||||
"second_reviewer": null,
|
||||
"second_reviewed_at": null,
|
||||
"requires_four_eyes": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Durchsetzung (UI + Dashboard)
|
||||
|
||||
Phase 3 macht das Anti-Fake-Evidence-System **benutzbar und durchsetzbar** im Frontend.
|
||||
|
||||
### Evidence Review/Reject UI
|
||||
|
||||
Die Evidence-Seite bietet jetzt direkte Buttons fuer Review und Ablehnung:
|
||||
|
||||
- **Reviewen-Button**: Sichtbar wenn `approvalStatus` nicht `approved` oder `rejected`
|
||||
- **Ablehnen-Button**: Sichtbar bei Four-Eyes-Evidence die noch nicht abgeschlossen ist
|
||||
|
||||
**ReviewModal** erlaubt:
|
||||
- Confidence-Level aendern (E0-E4 Dropdown)
|
||||
- Truth-Status aendern (Dropdown)
|
||||
- Reviewer E-Mail angeben
|
||||
- Four-Eyes-Warnung wenn noch ein weiterer Review noetig ist
|
||||
|
||||
**RejectModal** erlaubt:
|
||||
- Ablehnungsgrund als Freitext
|
||||
- Reviewer E-Mail angeben
|
||||
|
||||
Bei Four-Eyes Same-Person-Fehler (HTTP 400) wird eine Fehlermeldung angezeigt.
|
||||
|
||||
### Control Status-Transition Fehlerbehandlung
|
||||
|
||||
Die Controls-Seite zeigt jetzt detaillierte Fehler bei blockierten Status-Transitionen:
|
||||
|
||||
- **Optimistic Update mit Rollback**: UI aktualisiert sofort, rollt bei Fehler zurueck
|
||||
- **TransitionErrorBanner**: Zeigt Violations-Liste bei HTTP 409 Conflict
|
||||
- z.B. "Transition to 'pass' requires at least 1 evidence with confidence >= E2"
|
||||
- **Link zur Evidence-Seite**: "Evidence hinzufuegen" direkt im Fehler-Banner
|
||||
|
||||
### Evidence Audit-Trail Anzeige
|
||||
|
||||
Neuer "Historie"-Button auf jeder Evidence-Card zeigt den vollstaendigen Audit-Trail:
|
||||
|
||||
- Timeline mit Zeitstempel, Aktion und Akteur
|
||||
- Details zu Feldaenderungen (old_value → new_value)
|
||||
- Lazy-Loading: Erst beim Aufklappen wird `GET /audit-trail` abgerufen
|
||||
|
||||
### Evidence Confidence-Filter
|
||||
|
||||
Neue Filter-Pills auf der Evidence-Seite:
|
||||
|
||||
```
|
||||
[Alle] [Gueltig] [Abgelaufen] [Ausstehend] | [E0] [E1] [E2] [E3] [E4]
|
||||
```
|
||||
|
||||
Farbcodierung: E0=rot, E1=gelb, E2=blau, E3=gruen, E4=emerald (passend zu den Badges)
|
||||
|
||||
### Evidence Confidence-Verteilung (Dashboard)
|
||||
|
||||
Neuer Endpoint und Dashboard-Bereich:
|
||||
|
||||
**Endpoint:** `GET /dashboard/evidence-distribution`
|
||||
|
||||
```json
|
||||
{
|
||||
"by_confidence": {"E0": 2, "E1": 5, "E2": 3, "E3": 8, "E4": 1},
|
||||
"four_eyes_pending": 3,
|
||||
"total": 19
|
||||
}
|
||||
```
|
||||
|
||||
**Compliance Hub** zeigt:
|
||||
- Horizontal gestapelter Balken der Confidence-Verteilung (E0 rot → E4 emerald)
|
||||
- Multi-Score Dimensionen als Fortschrittsbalken (5 Dimensionen + Audit-Readiness)
|
||||
- Four-Eyes-Warteschlange (Anzahl pending)
|
||||
- Hard-Blocks-Liste oder "Keine Hard Blocks" Status
|
||||
|
||||
### Assertions-Seite
|
||||
|
||||
Neue Seite unter `/sdk/assertions` mit 3 Tabs:
|
||||
|
||||
| Tab | Inhalt |
|
||||
|-----|--------|
|
||||
| **Uebersicht** | Summary-Stats (Assertions, Facts, Rationale, Unverified) |
|
||||
| **Assertion-Liste** | Filterbarer Tabelle (entity_type, assertion_type) mit AssertionCards |
|
||||
| **Extraktion** | Textfeld + Button → `POST /assertions/extract` |
|
||||
|
||||
**AssertionCard** zeigt:
|
||||
- Normative-Tier als farbiger Badge (Pflicht=rot, Empfehlung=gelb, Kann=blau)
|
||||
- Typ-Badge (Assertion/Fact/Rationale)
|
||||
- "Als Fakt pruefen"-Button → `POST /assertions/{id}/verify`
|
||||
|
||||
### Phase 3: Neue API-Endpoints
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/dashboard/evidence-distribution` | Evidence-Verteilung nach Confidence + Four-Eyes-Status |
|
||||
@@ -88,12 +88,21 @@ compliance_evidence (
|
||||
|
||||
---
|
||||
|
||||
## Anti-Fake-Evidence
|
||||
|
||||
Seit Phase 1 (2026-03-23) werden Nachweise automatisch mit **Confidence Levels** (E0–E4) und **Truth Status** klassifiziert. Details: [Anti-Fake-Evidence Architektur](anti-fake-evidence.md)
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
**Testdatei:** `backend-compliance/tests/test_evidence_routes.py`
|
||||
**Anzahl Tests:** 11 · **Status:** ✅ alle bestanden (Stand 2026-03-05)
|
||||
|
||||
**Anti-Fake-Evidence Tests:** `backend-compliance/tests/test_anti_fake_evidence.py`
|
||||
**Anzahl Tests:** ~45 · Confidence-Klassifikation, State Machine, Multi-Score, LLM Audit
|
||||
|
||||
```bash
|
||||
cd backend-compliance
|
||||
python3 -m pytest tests/test_evidence_routes.py -v
|
||||
python3 -m pytest tests/test_evidence_routes.py tests/test_anti_fake_evidence.py -v
|
||||
```
|
||||
|
||||
@@ -109,6 +109,7 @@ nav:
|
||||
- Control Generator Pipeline: services/sdk-modules/control-generator-pipeline.md
|
||||
- Deduplizierungs-Engine: services/sdk-modules/dedup-engine.md
|
||||
- Control Provenance Wiki: services/sdk-modules/control-provenance.md
|
||||
- Anti-Fake-Evidence Architektur: services/sdk-modules/anti-fake-evidence.md
|
||||
- Strategie:
|
||||
- Wettbewerbsanalyse & Roadmap: strategy/wettbewerbsanalyse.md
|
||||
- Entwicklung:
|
||||
|
||||
Reference in New Issue
Block a user