fix(cra): Konformitätspfad-Kacheln korrekt benennen + Gating nach CRA Art. 32

(a) Labels: Module korrekt zugeordnet — Modul A = Selbstbewertung, Modul B+C =
    benannte Stelle, EUCC = eigenes Zertifikat (nicht Modul H), "harmonisierte
    Norm" ist kein Modul sondern Konformitätsvermutung. Für den CRA noch KEINE
    harmonisierte Norm veröffentlicht → Kachel als "noch nicht verfügbar"
    (erwartet ~2027), nicht wählbar, mit Hinweis. (page/path/documents-Labels.)
(b) Gating: wichtige Klasse II + kritische Produkte dürfen NICHT selbst bewerten;
    harmonisierte Norm allein genügt dort nicht → ALLOWED_PATHS IMPORTANT_II/
    CRITICAL = {eucc, notified_body}; DEFAULT_FOR II = notified_body. _PATH_HINT
    entsprechend. Regressionstest test_cra_conformity_paths.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-15 13:49:00 +02:00
parent e1f89f6226
commit 731076835d
8 changed files with 105 additions and 61 deletions
@@ -192,7 +192,7 @@ export default function DocumentsPage({
<input value={manufacturer} onChange={e => setManufacturer(e.target.value)} placeholder="Acme GmbH, Musterstr. 1, 80331 Muenchen" className="w-full px-3 py-2 border rounded text-sm" /> <input value={manufacturer} onChange={e => setManufacturer(e.target.value)} placeholder="Acme GmbH, Musterstr. 1, 80331 Muenchen" className="w-full px-3 py-2 border rounded text-sm" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-600 mb-1">Notified Body (falls Modul C)</label> <label className="block text-xs text-gray-600 mb-1">Benannte Stelle (falls Modul B+C)</label>
<input value={notifiedBody} onChange={e => setNotifiedBody(e.target.value)} placeholder="TUEV Nord (NB-0044)" className="w-full px-3 py-2 border rounded text-sm" /> <input value={notifiedBody} onChange={e => setNotifiedBody(e.target.value)} placeholder="TUEV Nord (NB-0044)" className="w-full px-3 py-2 border rounded text-sm" />
</div> </div>
<div> <div>
@@ -39,10 +39,10 @@ interface BacklogData {
} }
const PATH_LABEL: Record<string, string> = { const PATH_LABEL: Record<string, string> = {
self_assessment: 'Modul A — Self-Assessment', self_assessment: 'Modul A — Selbstbewertung',
harmonized_standard: 'Modul B — Harmonized Standard', harmonized_standard: 'Harmonisierte Norm (noch nicht verfügbar)',
eucc: 'Modul H — EUCC', eucc: 'EUCC — EU-Cybersicherheitszertifikat',
notified_body: 'Modul C — Notified Body', notified_body: 'Modul B+C — Benannte Stelle',
} }
export default function CRAProjectDashboard({ export default function CRAProjectDashboard({
@@ -20,55 +20,58 @@ interface PathOption {
title: string title: string
short: string short: string
details: string[] details: string[]
available?: boolean // false = rechtlich vorgesehen, aber noch nicht nutzbar
note?: string
} }
const PATHS: PathOption[] = [ const PATHS: PathOption[] = [
{ {
id: 'self_assessment', id: 'self_assessment',
modul: 'Modul A', modul: 'Modul A',
title: 'Self-Assessment', title: 'Selbstbewertung (interne Kontrolle)',
short: 'Konformitaetsbewertung durch interne Pruefung', short: 'Hersteller erklaert die Konformitaet selbst',
details: [ details: [
'Hersteller fuehrt Konformitaetsbewertung selbst durch', 'Hersteller fuehrt die Konformitaetsbewertung selbst durch',
'Geringster externer Aufwand, schnelle Umsetzung', 'Geringster externer Aufwand, schnelle Umsetzung',
'Default fuer Standard-Produkte', 'Nur fuer Standard- und (mit Norm) wichtige Klasse-I-Produkte',
'Technische Dokumentation + DoC bleibt Pflicht', 'Technische Dokumentation + Konformitaetserklaerung bleiben Pflicht',
], ],
}, },
{ {
id: 'harmonized_standard', id: 'harmonized_standard',
modul: 'Modul B', modul: 'Konformitaetsvermutung',
title: 'Harmonized Standard', title: 'Harmonisierte Normen',
short: 'Konformitaetsvermutung durch harmonisierte Norm', short: 'Vermutungswirkung durch eine harmonisierte EU-Norm',
available: false,
note: 'Fuer den CRA noch keine harmonisierte Norm veroeffentlicht — Entwuerfe erwartet ~Ende 2026, Listung im Amtsblatt voraussichtlich 2027.',
details: [ details: [
'Anwendung einer harmonisierten EU-Norm (z.B. DIN EN 40000-1-2 Entwurf)', 'Kein eigenes Modul, sondern Grundlage der Konformitaetsvermutung',
'Konformitaetsvermutung gemaess EU-Recht', 'Wer danach baut, gilt als CRA-konform und darf (Standard/Klasse I) selbst bewerten',
'Geringeres Audit-Risiko', 'Bis dahin ggf. ueber gemeinsame Spezifikationen der Kommission (Art. 27)',
'Empfohlen bei verfuegbarer harmonisierter Norm',
], ],
}, },
{ {
id: 'eucc', id: 'eucc',
modul: 'Modul H', modul: 'EUCC',
title: 'EUCC Zertifizierung', title: 'EU-Cybersicherheitszertifikat',
short: 'European Cybersecurity Certification Scheme', short: 'Zertifizierung nach EUCC-Schema (Common-Criteria-basiert)',
details: [ details: [
'ENISA-EUCC-Zertifizierung (Common Criteria-basiert)', 'EUCC-Zertifikat (in der Regel Stufe „substanziell")',
'Hoechste Anerkennung in EU + Drittstaaten', 'Eigener Weg unter dem Cybersecurity Act, keine benannte Stelle noetig',
'Hoher Aufwand, ITSEF-Pruefung erforderlich', 'Hohe Anerkennung in EU + Drittstaaten, ITSEF-Pruefung',
'Pflicht bei einigen Important Class II-Produkten', 'Regulaerer Weg fuer wichtige Klasse II und kritische Produkte',
], ],
}, },
{ {
id: 'notified_body', id: 'notified_body',
modul: 'Modul C', modul: 'Modul B+C',
title: 'Notified Body Assessment', title: 'Benannte Stelle (Baumusterpruefung)',
short: 'Drittprueforganisation pruefn die Konformitaet', short: 'Dritte Stelle prueft (EU-Baumusterpruefung + Produktionskontrolle)',
details: [ details: [
'Externe Bewertung durch akkreditierte Stelle', 'EU-Baumusterpruefung (Modul B) durch akkreditierte benannte Stelle',
'PFLICHT fuer Critical-Produkte (Annex IV)', 'gefolgt von Produktionskontrolle (Modul C); alternativ volle QS (Modul H)',
'Hoechste Auditierbarkeit + Vertrauen', 'Pflichtweg fuer wichtige Klasse II und kritische Produkte (Annex IV)',
'Laufzeit + Kosten am hoechsten', 'Hoechste Auditierbarkeit, hoechste Laufzeit + Kosten',
], ],
}, },
] ]
@@ -76,14 +79,15 @@ const PATHS: PathOption[] = [
const ALLOWED: Record<string, PathId[]> = { const ALLOWED: Record<string, PathId[]> = {
STANDARD: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'], STANDARD: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_I: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'], IMPORTANT_I: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_II: ['harmonized_standard', 'eucc', 'notified_body'], // Klasse II darf nicht selbst bewerten; harmonisierte Norm allein genuegt nicht.
CRITICAL: ['notified_body'], IMPORTANT_II: ['eucc', 'notified_body'],
CRITICAL: ['eucc', 'notified_body'],
} }
const DEFAULT_FOR: Record<string, PathId> = { const DEFAULT_FOR: Record<string, PathId> = {
STANDARD: 'self_assessment', STANDARD: 'self_assessment',
IMPORTANT_I: 'self_assessment', IMPORTANT_I: 'self_assessment',
IMPORTANT_II: 'harmonized_standard', IMPORTANT_II: 'notified_body',
CRITICAL: 'notified_body', CRITICAL: 'notified_body',
} }
@@ -185,31 +189,37 @@ export default function PathSelectPage({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{PATHS.map(path => { {PATHS.map(path => {
const allowed = allowedPaths.includes(path.id) const allowed = allowedPaths.includes(path.id)
const available = path.available !== false
const selectable = allowed && available
const isSelected = selected === path.id const isSelected = selected === path.id
return ( return (
<button <button
key={path.id} key={path.id}
onClick={() => allowed && setSelected(path.id)} onClick={() => selectable && setSelected(path.id)}
disabled={!allowed} disabled={!selectable}
className={`text-left p-5 rounded-xl border-2 transition-all ${ className={`text-left p-5 rounded-xl border-2 transition-all ${
isSelected ? 'border-red-500 bg-red-50' : isSelected ? 'border-red-500 bg-red-50' :
allowed ? 'border-gray-200 bg-white hover:border-red-300 hover:shadow-md' : selectable ? 'border-gray-200 bg-white hover:border-red-300 hover:shadow-md' :
'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed' 'border-gray-200 bg-gray-50 opacity-60 cursor-not-allowed'
}`} }`}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2 gap-2">
<div> <div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{path.modul}</span> <span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{path.modul}</span>
<h3 className="text-lg font-semibold text-gray-900">{path.title}</h3> <h3 className="text-lg font-semibold text-gray-900">{path.title}</h3>
</div> </div>
{isSelected && ( {isSelected ? (
<span className="px-2 py-0.5 text-xs bg-red-600 text-white rounded">Gewaehlt</span> <span className="px-2 py-0.5 text-xs bg-red-600 text-white rounded whitespace-nowrap">Gewaehlt</span>
)} ) : !available ? (
{!allowed && ( <span className="px-2 py-0.5 text-xs bg-amber-100 text-amber-700 rounded whitespace-nowrap">Noch nicht verfügbar</span>
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">Nicht zulaessig</span> ) : !allowed ? (
)} <span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded whitespace-nowrap">Nicht zulaessig</span>
) : null}
</div> </div>
<p className="text-sm text-gray-600 mb-3">{path.short}</p> <p className="text-sm text-gray-600 mb-2">{path.short}</p>
{path.note && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1.5 mb-2">{path.note}</p>
)}
<ul className="text-xs text-gray-600 space-y-1"> <ul className="text-xs text-gray-600 space-y-1">
{path.details.map((d, i) => ( {path.details.map((d, i) => (
<li key={i} className="flex items-start gap-1.5"> <li key={i} className="flex items-start gap-1.5">
+4 -4
View File
@@ -16,10 +16,10 @@ interface CRAProject {
} }
const PATH_LABEL: Record<string, string> = { const PATH_LABEL: Record<string, string> = {
self_assessment: 'Modul A (Self-Assessment)', self_assessment: 'Modul A (Selbstbewertung)',
harmonized_standard: 'Modul B (Harmonized)', harmonized_standard: 'Harmonisierte Norm (noch nicht verfügbar)',
eucc: 'Modul H (EUCC)', eucc: 'EUCC-Zertifikat',
notified_body: 'Modul C (Notified Body)', notified_body: 'Modul B+C (Benannte Stelle)',
} }
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
@@ -135,10 +135,10 @@ class ReadinessRequest(BaseModel):
# CRA Annex I evidence_type -> guideline bucket (Code / Prozess / Dokumentation). # CRA Annex I evidence_type -> guideline bucket (Code / Prozess / Dokumentation).
_GUIDELINE_BUCKET = {"code": "code", "hybrid": "code", "process": "process", "document": "document"} _GUIDELINE_BUCKET = {"code": "code", "hybrid": "code", "process": "process", "document": "document"}
_PATH_HINT = { _PATH_HINT = {
"CRITICAL": "Konformitaet ueber benannte Stelle / EUCC (Modul H/C)", "CRITICAL": "Benannte Stelle (Modul B+C/H) oder EUCC — keine Selbstbewertung",
"IMPORTANT_II": "Modul B+C oder harmonisierte Norm", "IMPORTANT_II": "Benannte Stelle (Modul B+C/H) oder EUCC — keine Selbstbewertung",
"IMPORTANT_I": "Self-Assessment bei harmonisierten Normen, sonst Modul B", "IMPORTANT_I": "Selbstbewertung nur mit harmonisierter Norm (noch nicht verfuegbar), sonst benannte Stelle/EUCC",
"STANDARD": "Self-Assessment (Modul A)", "STANDARD": "Selbstbewertung (Modul A)",
"NOT_IN_SCOPE": "", "NOT_IN_SCOPE": "",
} }
@@ -17,10 +17,10 @@ from typing import Optional
PATH_LABELS = { PATH_LABELS = {
"self_assessment": "Modul A — Self-Assessment durch Hersteller", "self_assessment": "Modul A — Interne Kontrolle (Selbstbewertung durch Hersteller)",
"harmonized_standard": "Modul B — Harmonisierte Norm", "harmonized_standard": "Harmonisierte Normen — Konformitaetsvermutung (noch nicht verfuegbar)",
"eucc": "Modul H — EUCC-Zertifizierung", "eucc": "EUCC — EU-Cybersicherheitszertifikat",
"notified_body": "Modul C — Konformitaetsbewertung durch Notified Body", "notified_body": "Modul B+C — Pruefung durch benannte Stelle (Baumusterpruefung)",
} }
CLASS_LABELS = { CLASS_LABELS = {
@@ -67,13 +67,16 @@ ANNEX_III_CLASS_I = [
CLASSIFICATIONS = {"NOT_IN_SCOPE", "STANDARD", "IMPORTANT_I", "IMPORTANT_II", "CRITICAL"} CLASSIFICATIONS = {"NOT_IN_SCOPE", "STANDARD", "IMPORTANT_I", "IMPORTANT_II", "CRITICAL"}
CONFORMITY_PATHS = {"self_assessment", "harmonized_standard", "eucc", "notified_body"} CONFORMITY_PATHS = {"self_assessment", "harmonized_standard", "eucc", "notified_body"}
# Allowed paths per classification (CRITICAL must use notified_body) # Allowed paths per classification (CRA Art. 32 / Anhang VIII).
# IMPORTANT_II darf NICHT selbst bewerten — eine harmonisierte Norm allein genuegt
# hier nicht; es braucht benannte Stelle (Modul B+C/H) oder EUCC. CRITICAL ebenso,
# mit EUCC als regulaerem Weg.
ALLOWED_PATHS = { ALLOWED_PATHS = {
"NOT_IN_SCOPE": set(), "NOT_IN_SCOPE": set(),
"STANDARD": {"self_assessment", "harmonized_standard", "eucc", "notified_body"}, "STANDARD": {"self_assessment", "harmonized_standard", "eucc", "notified_body"},
"IMPORTANT_I": {"self_assessment", "harmonized_standard", "eucc", "notified_body"}, "IMPORTANT_I": {"self_assessment", "harmonized_standard", "eucc", "notified_body"},
"IMPORTANT_II": {"harmonized_standard", "eucc", "notified_body"}, "IMPORTANT_II": {"eucc", "notified_body"},
"CRITICAL": {"notified_body"}, "CRITICAL": {"eucc", "notified_body"},
} }
STATUS_WHITELIST = { STATUS_WHITELIST = {
@@ -0,0 +1,31 @@
"""Pin the CRA conformity-path gating (CRA Art. 32 / Anhang VIII).
Regression: wichtige Klasse-II- und kritische Produkte duerfen NICHT selbst
bewerten eine harmonisierte Norm allein genuegt dort nicht.
"""
from compliance.api.cra_routes import ALLOWED_PATHS, CONFORMITY_PATHS
def test_self_assessment_only_for_standard_and_important_i():
assert "self_assessment" in ALLOWED_PATHS["STANDARD"]
assert "self_assessment" in ALLOWED_PATHS["IMPORTANT_I"]
assert "self_assessment" not in ALLOWED_PATHS["IMPORTANT_II"]
assert "self_assessment" not in ALLOWED_PATHS["CRITICAL"]
def test_important_ii_needs_notified_body_or_eucc():
# Klasse II: harmonisierte Norm allein ist KEIN gueltiger Pfad.
assert ALLOWED_PATHS["IMPORTANT_II"] == {"eucc", "notified_body"}
def test_critical_allows_eucc_and_notified_body():
assert ALLOWED_PATHS["CRITICAL"] == {"eucc", "notified_body"}
def test_not_in_scope_has_no_paths():
assert ALLOWED_PATHS["NOT_IN_SCOPE"] == set()
def test_all_allowed_paths_are_known():
for paths in ALLOWED_PATHS.values():
assert paths <= CONFORMITY_PATHS