feat(sdk): Kunden-Dokumente + CRA-Meldewesen, Screening aus Frontend genommen
CI / detect-changes (push) Successful in 16s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 15s
CI / validate-canonical-controls (push) Successful in 13s
CI / loc-budget (push) Successful in 25s
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 3m9s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 31s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

- /sdk/dokumente: Kundensicht nur auf veroeffentlichte Rechtsdokumente
  (Ansehen + Download); Proxy mit Allow-List nur /public — Templates/Drafts/
  Generator bleiben unerreichbar.
- /sdk/cra-meldewesen: CRA Art. 14 Meldewesen (24h/72h/14d-Kaskade) mit
  Fristen-Tracking + ENISA-SRP-Export-Entwurf (kein Live-API). Backend:
  cra_meldewesen (pure, getestet) + cra_incident_store (schema-neutral ueber
  compliance_cra_documents) + /api/v1/cra/incidents (additiv, contract-safe).
- Screening (Self-Scan) aus dem Frontend genommen: Flow-Stepper-Eintrag
  ausgeblendet (visibleWhen), Dashboard-Kachel + Import-Button entfernt.
  Repo-Scanning laeuft extern im Compliance-Scanner; Backend-Router bleibt
  vorerst gemountet (Contract-Stabilitaet).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Bönisch
2026-06-17 21:21:28 +02:00
parent 72093e5501
commit 8f21650d74
17 changed files with 1155 additions and 17 deletions
@@ -0,0 +1,132 @@
"""CRA Article 14 incident-reporting (Meldewesen) endpoints.
The 24h/72h/14d cascade to ENISA's SRP. Thin handlers delegate to the pure
cra_meldewesen logic + the schema-neutral cra_incident_store. There is no live
ENISA API — submissions store a downloadable export draft.
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from compliance.services import cra_incident_store as store
from compliance.services.cra_meldewesen import (
STAGES, SEVERITIES, KINDS, REPORTING_ACTIVE_FROM,
compute_deadlines, next_open_stage, build_enisa_report, report_completeness,
)
from .tenant_utils import get_tenant_id
router = APIRouter(prefix="/v1/cra", tags=["cra-meldewesen"])
class IncidentCreate(BaseModel):
cra_project_id: str
summary: str = ""
product_name: str = ""
product_version: str = ""
manufacturer: str = ""
kind: str = "exploited_vulnerability"
severity: str = "high"
aware_at: str = ""
contact: str = ""
impact: str = ""
affected_components: str = ""
exploitation_status: str = ""
iocs: str = ""
mitigations: str = ""
personal_data_affected: bool = False
root_cause: str = ""
corrective_measures: str = ""
patch_available: bool = False
patch_reference: str = ""
lessons_learned: str = ""
class IncidentPatch(BaseModel):
summary: Optional[str] = None
impact: Optional[str] = None
affected_components: Optional[str] = None
exploitation_status: Optional[str] = None
iocs: Optional[str] = None
mitigations: Optional[str] = None
personal_data_affected: Optional[bool] = None
root_cause: Optional[str] = None
corrective_measures: Optional[str] = None
patch_available: Optional[bool] = None
patch_reference: Optional[str] = None
lessons_learned: Optional[str] = None
def _enrich(inc: dict) -> dict:
"""Attach computed deadlines + next open stage (derived, never stored)."""
subs = {k: v.get("submitted_at") for k, v in (inc.get("submissions") or {}).items()}
deadlines = compute_deadlines(inc.get("aware_at", ""), subs)
return {**inc, "deadlines": deadlines, "next_stage": next_open_stage(deadlines)}
@router.get("/incidents/meta")
async def incident_meta() -> dict:
return {"stages": STAGES, "severities": SEVERITIES, "kinds": KINDS,
"reporting_active_from": REPORTING_ACTIVE_FROM}
@router.post("/incidents")
async def create_incident(body: IncidentCreate, tenant_id: str = Depends(get_tenant_id)) -> dict:
data = body.model_dump()
pid = data.pop("cra_project_id")
if not data.get("aware_at"):
data["aware_at"] = datetime.now(timezone.utc).isoformat()
return _enrich(store.create_incident(pid, tenant_id, data))
@router.get("/incidents")
async def list_incidents(cra_project_id: str, tenant_id: str = Depends(get_tenant_id)) -> dict:
items = [_enrich(i) for i in store.list_incidents(cra_project_id, tenant_id)]
return {"incidents": items, "count": len(items)}
@router.get("/incidents/{incident_id}")
async def get_incident(incident_id: str, tenant_id: str = Depends(get_tenant_id)) -> dict:
inc = store.get_incident(incident_id, tenant_id)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
return _enrich(inc)
@router.patch("/incidents/{incident_id}")
async def update_incident(incident_id: str, body: IncidentPatch,
tenant_id: str = Depends(get_tenant_id)) -> dict:
patch = {k: v for k, v in body.model_dump().items() if v is not None}
inc = store.update_incident(incident_id, tenant_id, patch)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
return _enrich(inc)
@router.get("/incidents/{incident_id}/export/{stage}")
async def export_stage(incident_id: str, stage: str,
tenant_id: str = Depends(get_tenant_id)) -> dict:
inc = store.get_incident(incident_id, tenant_id)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
try:
report = build_enisa_report(inc, stage)
except ValueError:
raise HTTPException(status_code=400, detail="Unbekannte Meldestufe")
return {"report": report, "completeness": report_completeness(inc, stage)}
@router.post("/incidents/{incident_id}/submit/{stage}")
async def submit_stage(incident_id: str, stage: str,
tenant_id: str = Depends(get_tenant_id)) -> dict:
inc = store.get_incident(incident_id, tenant_id)
if not inc:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
try:
report = build_enisa_report(inc, stage)
except ValueError:
raise HTTPException(status_code=400, detail="Unbekannte Meldestufe")
ts = datetime.now(timezone.utc).isoformat()
updated = store.record_submission(incident_id, tenant_id, stage, ts, report)
return _enrich(updated)