"""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)