feat(use-case-compiler): MC-based compliance questionnaires with scoring
Build + Deploy / build-admin-compliance (push) Successful in 2m46s
Build + Deploy / build-backend-compliance (push) Successful in 26s
Build + Deploy / build-ai-sdk (push) Successful in 52s
Build + Deploy / build-developer-portal (push) Successful in 22s
Build + Deploy / build-tts (push) Successful in 16s
Build + Deploy / build-document-crawler (push) Successful in 12s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
Build + Deploy / build-dsms-node (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 18s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m16s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 1m0s
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Successful in 29s
CI / test-python-dsms-gateway (push) Successful in 23s
CI / validate-canonical-controls (push) Successful in 16s
Build + Deploy / trigger-orca (push) Successful in 2m36s

Implements the Use-Case Compiler that turns Master Controls into
interactive compliance audits. 5 templates (Vendor Check, SAST/DAST,
DSGVO, NIS2, CRA), deterministic + LLM question generation, scoring
engine with regulation/severity breakdown, and gap detection.

- Backend: 9 API endpoints, 22 unit tests (all pass)
- Frontend: Template selector, questionnaire, result dashboard
- Migration 027: usecase_audits + usecase_answers tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-12 13:49:16 +02:00
parent 74f00bbb0f
commit 06bfbd1dca
22 changed files with 3157 additions and 1 deletions
@@ -0,0 +1,142 @@
'use client'
import React from 'react'
interface Audit {
id: string
name: string
target_name: string
total_questions: number
answered_questions: number
}
interface ScoreResult {
audit_id: string
total_questions: number
answered: number
passed: number
failed: number
skipped: number
compliance_score: number
by_regulation: Record<string, { total: number; passed: number; score: number }>
by_severity: Record<string, { total: number; passed: number; failed: number }>
}
interface Props {
audit: Audit
score: ScoreResult
}
function scoreColor(score: number): string {
if (score >= 80) return 'text-green-600'
if (score >= 50) return 'text-yellow-600'
return 'text-red-600'
}
function scoreBg(score: number): string {
if (score >= 80) return 'bg-green-100 border-green-200'
if (score >= 50) return 'bg-yellow-100 border-yellow-200'
return 'bg-red-100 border-red-200'
}
export function AuditResult({ audit, score }: Props) {
const regEntries = Object.entries(score.by_regulation)
const sevEntries = Object.entries(score.by_severity)
return (
<div className="space-y-6">
{/* Score Overview */}
<div className={`rounded-xl border p-6 text-center ${scoreBg(score.compliance_score)}`}>
<div className={`text-5xl font-bold ${scoreColor(score.compliance_score)}`}>
{Math.round(score.compliance_score)}%
</div>
<div className="text-gray-600 mt-2">Compliance Score</div>
<div className="text-sm text-gray-500 mt-1">
{audit.name}{audit.target_name ? `${audit.target_name}` : ''}
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{score.answered}</div>
<div className="text-xs text-gray-500 mt-1">Beantwortet</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-green-600">{score.passed}</div>
<div className="text-xs text-gray-500 mt-1">Bestanden</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-red-600">{score.failed}</div>
<div className="text-xs text-gray-500 mt-1">Nicht bestanden</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-400">{score.skipped}</div>
<div className="text-xs text-gray-500 mt-1">Uebersprungen</div>
</div>
</div>
{/* By Regulation */}
{regEntries.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Nach Regulierung</h3>
<div className="space-y-3">
{regEntries.map(([reg, data]) => (
<div key={reg} className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 w-32 truncate">{reg}</span>
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
data.score >= 80 ? 'bg-green-500' :
data.score >= 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${data.score}%` }}
/>
</div>
<span className={`text-sm font-bold w-12 text-right ${scoreColor(data.score)}`}>
{Math.round(data.score)}%
</span>
<span className="text-xs text-gray-400 w-16 text-right">
{data.passed}/{data.total}
</span>
</div>
))}
</div>
</div>
)}
{/* By Severity */}
{sevEntries.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-800 mb-4">Nach Schweregrad</h3>
<div className="grid grid-cols-3 gap-4">
{sevEntries.map(([sev, data]) => (
<div key={sev} className="text-center">
<div className={`text-sm font-medium mb-2 ${
sev === 'HIGH' ? 'text-red-600' :
sev === 'MEDIUM' ? 'text-yellow-600' : 'text-green-600'
}`}>
{sev}
</div>
<div className="text-lg font-bold text-gray-900">{data.passed}/{data.total}</div>
<div className="text-xs text-gray-400 mt-0.5">
{data.failed} nicht bestanden
</div>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3">
<a
href="/sdk/use-case-audit"
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
>
Zurueck zur Liste
</a>
</div>
</div>
)
}
@@ -0,0 +1,215 @@
'use client'
import React, { useState, useMemo } from 'react'
interface Question {
id: string
mc_id: string
mc_name: string
question: string
question_type: string
evidence_required: boolean
pass_criteria: string[]
fail_criteria: string[]
severity: string
regulation: string
depends_on?: string
}
interface Answer {
question_id: string
value: unknown
comment: string
status: string
}
interface Props {
questions: Question[]
answers: Answer[]
onAnswer: (questionId: string, value: unknown, comment: string) => void
onSkip: (questionId: string) => void
}
const SEVERITY_COLORS: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700 border-red-200',
MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200',
LOW: 'bg-green-100 text-green-700 border-green-200',
}
export function QuestionnaireView({ questions, answers, onAnswer, onSkip }: Props) {
const [comments, setComments] = useState<Record<string, string>>({})
const [showCriteria, setShowCriteria] = useState<string | null>(null)
const answerMap = useMemo(() => {
const m: Record<string, Answer> = {}
for (const a of answers) { m[a.question_id] = a }
return m
}, [answers])
// Filter visible questions (dependency check)
const visibleQuestions = useMemo(() => {
return questions.filter(q => {
if (!q.depends_on) return true
const depAnswer = answerMap[q.depends_on]
if (!depAnswer) return false
return depAnswer.value === true || depAnswer.value === 'yes' || depAnswer.value === 'ja'
})
}, [questions, answerMap])
// Group by regulation
const grouped = useMemo(() => {
const groups: Record<string, Question[]> = {}
for (const q of visibleQuestions) {
const key = q.regulation || 'Allgemein'
if (!groups[key]) groups[key] = []
groups[key].push(q)
}
return groups
}, [visibleQuestions])
return (
<div className="space-y-8">
{Object.entries(grouped).map(([reg, qs]) => (
<div key={reg}>
<h2 className="text-lg font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span className="px-2.5 py-0.5 bg-blue-100 text-blue-700 rounded text-sm font-medium">
{reg}
</span>
<span className="text-sm text-gray-400 font-normal">
{qs.length} Fragen
</span>
</h2>
<div className="space-y-3">
{qs.map((q, idx) => {
const existing = answerMap[q.id]
const isAnswered = !!existing && existing.status !== 'skipped'
const isSkipped = existing?.status === 'skipped'
const isPassed = existing?.value === true || existing?.value === 'yes'
return (
<div
key={q.id}
className={`bg-white rounded-xl border p-5 transition-all ${
isAnswered
? isPassed
? 'border-green-200 bg-green-50/30'
: 'border-red-200 bg-red-50/30'
: isSkipped
? 'border-gray-200 bg-gray-50/50 opacity-60'
: 'border-gray-200 hover:border-blue-200'
}`}
>
<div className="flex items-start gap-3">
<span className="text-xs text-gray-400 font-mono mt-1 shrink-0">
{q.id}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<p className="text-sm font-medium text-gray-900">{q.question}</p>
<div className="flex items-center gap-1.5 shrink-0">
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${
SEVERITY_COLORS[q.severity] || SEVERITY_COLORS.MEDIUM
}`}>
{q.severity}
</span>
{q.evidence_required && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
Nachweis
</span>
)}
</div>
</div>
{q.mc_name && (
<p className="text-xs text-gray-400 mb-2">
MC: {q.mc_name} {q.mc_id && `(${q.mc_id})`}
</p>
)}
{/* Criteria toggle */}
<button
onClick={() => setShowCriteria(showCriteria === q.id ? null : q.id)}
className="text-xs text-blue-500 hover:text-blue-700 mb-2"
>
{showCriteria === q.id ? 'Kriterien ausblenden' : 'Bewertungskriterien anzeigen'}
</button>
{showCriteria === q.id && (
<div className="grid grid-cols-2 gap-3 mb-3 text-xs">
{q.pass_criteria?.length > 0 && (
<div className="bg-green-50 border border-green-100 rounded p-2">
<div className="font-medium text-green-700 mb-1">Pass</div>
{q.pass_criteria.map((c, i) => (
<div key={i} className="text-green-600">{c}</div>
))}
</div>
)}
{q.fail_criteria?.length > 0 && (
<div className="bg-red-50 border border-red-100 rounded p-2">
<div className="font-medium text-red-700 mb-1">Fail</div>
{q.fail_criteria.map((c, i) => (
<div key={i} className="text-red-600">{c}</div>
))}
</div>
)}
</div>
)}
{/* Answer controls */}
{!isAnswered && !isSkipped && (
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => onAnswer(q.id, true, comments[q.id] || '')}
className="px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
>
Ja
</button>
<button
onClick={() => onAnswer(q.id, false, comments[q.id] || '')}
className="px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
>
Nein
</button>
<button
onClick={() => onSkip(q.id)}
className="px-3 py-1.5 text-gray-400 hover:text-gray-600 text-sm"
>
Ueberspringen
</button>
<input
type="text"
placeholder="Kommentar (optional)"
value={comments[q.id] || ''}
onChange={e => setComments(prev => ({ ...prev, [q.id]: e.target.value }))}
className="flex-1 px-3 py-1.5 border border-gray-200 rounded-lg text-sm focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
{/* Already answered */}
{isAnswered && (
<div className="flex items-center gap-2 mt-2 text-sm">
<span className={isPassed ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
{isPassed ? 'Ja' : 'Nein'}
</span>
{existing.comment && (
<span className="text-gray-400"> {existing.comment}</span>
)}
</div>
)}
{isSkipped && (
<div className="text-sm text-gray-400 mt-2">Uebersprungen</div>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,138 @@
'use client'
import React, { useState } from 'react'
interface Template {
id: string
name: string
description: string
mc_filters: string[]
regulations: string[]
}
interface Props {
templates: Template[]
onCreateAudit: (templateId: string, name: string, targetName: string) => void
loading: boolean
}
const REG_COLORS: Record<string, string> = {
dsgvo: 'bg-purple-100 text-purple-700',
nis2: 'bg-orange-100 text-orange-700',
cra: 'bg-red-100 text-red-700',
owasp: 'bg-yellow-100 text-yellow-700',
}
export function UseCaseSelector({ templates, onCreateAudit, loading }: Props) {
const [selected, setSelected] = useState<Template | null>(null)
const [auditName, setAuditName] = useState('')
const [targetName, setTargetName] = useState('')
const handleSelect = (t: Template) => {
setSelected(t)
setAuditName(t.name)
setTargetName('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selected || !auditName.trim()) return
onCreateAudit(selected.id, auditName.trim(), targetName.trim())
}
return (
<div>
{!selected ? (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">Template auswaehlen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map(t => (
<button
key={t.id}
onClick={() => handleSelect(t)}
className="text-left bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all"
>
<h3 className="font-semibold text-gray-900 mb-2">{t.name}</h3>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{t.description}</p>
<div className="flex flex-wrap gap-1.5">
{t.regulations.map(r => (
<span
key={r}
className={`px-2 py-0.5 rounded text-xs font-medium ${REG_COLORS[r] || 'bg-gray-100 text-gray-600'}`}
>
{r.toUpperCase()}
</span>
))}
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-xs">
{t.mc_filters.length} Filter
</span>
</div>
</button>
))}
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<button
type="button"
onClick={() => setSelected(null)}
className="text-gray-400 hover:text-gray-600"
>
&larr;
</button>
<div>
<h2 className="text-xl font-semibold text-gray-800">{selected.name}</h2>
<p className="text-sm text-gray-500">{selected.description}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Audit-Name *
</label>
<input
type="text"
value={auditName}
onChange={e => setAuditName(e.target.value)}
placeholder={`z.B. "${selected.name}: Firma XY"`}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zielobjekt (optional)
</label>
<input
type="text"
value={targetName}
onChange={e => setTargetName(e.target.value)}
placeholder="z.B. AWS, SAP, Produkt XY"
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
type="submit"
disabled={loading || !auditName.trim()}
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm disabled:opacity-50"
>
{loading ? 'Wird erstellt...' : 'Audit starten'}
</button>
<button
type="button"
onClick={() => setSelected(null)}
className="px-4 py-2.5 text-gray-600 hover:text-gray-800 text-sm"
>
Abbrechen
</button>
</div>
</form>
)}
</div>
)
}