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,142 @@
"""Persist CRA Art. 14 incident reports — schema-neutral (frozen DB).
Reuses the existing compliance_cra_documents table (doc_type='doc_incident_report'):
one row per incident, the full incident + per-stage submissions live in
generation_context (jsonb). No supersede chaining (unlike the assessment
snapshot store) — each incident is an independent, evolving record.
"""
import json
from typing import Optional
from sqlalchemy import text
from database import SessionLocal
DOC_TYPE = "doc_incident_report"
def _summary_md(inc: dict) -> str:
return "\n".join([
"# CRA-Meldung: {}".format(inc.get("summary", "(ohne Titel)")),
"",
"- Produkt: {} {}".format(inc.get("product_name", ""), inc.get("product_version", "")),
"- Art: {} · Schwere: {}".format(inc.get("kind", ""), inc.get("severity", "")),
"- Bekannt seit: {}".format(inc.get("aware_at", "")),
])
def create_incident(cra_project_id: str, tenant_id: str, incident: dict) -> dict:
inc = dict(incident)
inc.setdefault("submissions", {}) # stage_key -> {submitted_at, report}
db = SessionLocal()
try:
row = db.execute(text("""
INSERT INTO compliance_cra_documents
(cra_project_id, tenant_id, doc_type, title, content_md, version,
requirements_coverage, generation_context, status)
VALUES (CAST(:pid AS uuid), :tid, :dt, :title, :md, 1,
CAST('{}' AS jsonb), CAST(:ctx AS jsonb), 'open')
RETURNING id, generated_at
"""), {"pid": cra_project_id, "tid": tenant_id, "dt": DOC_TYPE,
"title": inc.get("summary", "CRA-Meldung")[:200],
"md": _summary_md(inc), "ctx": json.dumps(inc)}).fetchone()
db.commit()
return {"id": str(row.id), "created_at": row.generated_at.isoformat(),
"status": "open", **inc}
except Exception:
db.rollback()
raise
finally:
db.close()
def list_incidents(cra_project_id: str, tenant_id: str) -> list:
db = SessionLocal()
try:
rows = db.execute(text("""
SELECT id, status, generated_at, generation_context
FROM compliance_cra_documents
WHERE cra_project_id = CAST(:pid AS uuid) AND tenant_id = :tid AND doc_type = :dt
ORDER BY generated_at DESC
"""), {"pid": cra_project_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchall()
out = []
for r in rows:
ctx = r.generation_context if isinstance(r.generation_context, dict) else {}
out.append({"id": str(r.id), "status": r.status,
"created_at": r.generated_at.isoformat(), **ctx})
return out
finally:
db.close()
def get_incident(incident_id: str, tenant_id: str) -> Optional[dict]:
db = SessionLocal()
try:
r = db.execute(text("""
SELECT id, status, generated_at, generation_context
FROM compliance_cra_documents
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone()
if not r:
return None
ctx = r.generation_context if isinstance(r.generation_context, dict) else {}
return {"id": str(r.id), "status": r.status,
"created_at": r.generated_at.isoformat(), **ctx}
finally:
db.close()
def _save_ctx(db, incident_id: str, tenant_id: str, ctx: dict, status: str) -> None:
db.execute(text("""
UPDATE compliance_cra_documents
SET generation_context = CAST(:ctx AS jsonb), status = :st, updated_at = NOW()
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"ctx": json.dumps(ctx), "st": status, "iid": incident_id,
"tid": tenant_id, "dt": DOC_TYPE})
def update_incident(incident_id: str, tenant_id: str, patch: dict) -> Optional[dict]:
db = SessionLocal()
try:
r = db.execute(text("""
SELECT status, generation_context FROM compliance_cra_documents
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone()
if not r:
return None
ctx = dict(r.generation_context if isinstance(r.generation_context, dict) else {})
ctx.update({k: v for k, v in patch.items() if k != "submissions"})
_save_ctx(db, incident_id, tenant_id, ctx, r.status)
db.commit()
return {"id": incident_id, "status": r.status, **ctx}
except Exception:
db.rollback()
raise
finally:
db.close()
def record_submission(incident_id: str, tenant_id: str, stage: str,
submitted_at: str, report: dict) -> Optional[dict]:
"""Mark a cascade stage as submitted (stores the ENISA export draft + ts)."""
db = SessionLocal()
try:
r = db.execute(text("""
SELECT generation_context FROM compliance_cra_documents
WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt
"""), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone()
if not r:
return None
ctx = dict(r.generation_context if isinstance(r.generation_context, dict) else {})
subs = dict(ctx.get("submissions") or {})
subs[stage] = {"submitted_at": submitted_at, "report": report}
ctx["submissions"] = subs
status = "closed" if stage == "final" else "reporting"
_save_ctx(db, incident_id, tenant_id, ctx, status)
db.commit()
return {"id": incident_id, "status": status, **ctx}
except Exception:
db.rollback()
raise
finally:
db.close()