Files
breakpilot-compliance/backend-compliance/compliance/api/cra_routes.py
T
Benjamin Admin cc80e59e5e feat(cra): Phase 4 — Vulnerability Disclosure + Post-Market Monitoring
Migration 121: compliance_cra_vulnerabilities table with full lifecycle tracking
- Status state machine: reported → triaged → patched → disclosed (+ withdrawn)
- CRA Art. 14(2) deadlines tracked: reported_to_enisa_at (24h), detailed_report_at (72h)
- CVE-ID, severity, CVSS, affected_components (JSONB), embargo_until

Backend endpoints in cra_routes.py:
- POST /vulnerabilities — create with validation (severity, CVSS range)
- GET /vulnerabilities — list with deadline-breach summary (24h/72h counters)
- PATCH /vulnerabilities/{id} — update fields + auto-set lifecycle timestamps
- DELETE /vulnerabilities/{id} — soft-delete (withdrawn)
- GET /monitoring — combined view: CRA deadlines + vuln summary + post-market checklist

Frontend:
- /vuln page: intake form, vuln cards with 24h/72h-countdown buttons,
  status-transition flow with auto-timestamps
- /monitoring page: CRA deadlines (11.06.26 / 11.09.26 / 11.12.27), breach banner
  if 24h/72h obligations missed, post-market checklist with deep-links
- Dashboard: +2 buttons (Vulns, Monitoring)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:08:49 +02:00

1715 lines
62 KiB
Python

"""
FastAPI routes for CRA (Cyber Resilience Act) Compliance Projects.
Endpoints:
- GET /v1/cra/projects List all CRA projects for a tenant
- POST /v1/cra/projects Create a new CRA project
- GET /v1/cra/projects/{id} Get a single CRA project
- PATCH /v1/cra/projects/{id} Update intake fields / status
- DELETE /v1/cra/projects/{id} Archive project
- POST /v1/cra/projects/{id}/scope-check Run deterministic Annex III/IV match
- POST /v1/cra/projects/{id}/path-select Set conformity path (validated against classification)
Tenant scoping via X-Tenant-ID header (see tenant_utils.get_tenant_id).
"""
import json
import logging
import socket
import ssl
from datetime import date
from typing import Optional
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from sqlalchemy import text
from database import SessionLocal
from .cra_annex_i_data import (
ANNEX_I_REQUIREMENTS, MEASURES, DEADLINES, SEVERITY_WEIGHT,
)
from .tenant_utils import get_tenant_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/cra/projects", tags=["cra"])
# =============================================================================
# CONSTANTS — Annex III/IV mapping (source: migration 059_wiki_cra_annex_i_detail.sql)
# =============================================================================
ANNEX_IV_CRITICAL = [
"hardware-security-module", "hardware security module", "hsm",
"smartcard-chip", "smartcard chip", "smart-meter-gateway", "smart meter gateway",
"secure-element", "secure element",
]
ANNEX_III_CLASS_II = [
"betriebssystem", "operating system",
"hypervisor",
"container-runtime", "container runtime",
"public-key-infrastruktur", "public key infrastructure", "pki",
"industrial-control", "industrial control", "ics", "scada",
]
ANNEX_III_CLASS_I = [
"passwort-manager", "password manager", "passwort manager",
"vpn-software", "vpn software",
"firewall",
"router",
"smart-home", "smart home",
"iot-sensor", "iot sensor", "iot-geraet",
"siem", "siem-system",
]
CLASSIFICATIONS = {"NOT_IN_SCOPE", "STANDARD", "IMPORTANT_I", "IMPORTANT_II", "CRITICAL"}
CONFORMITY_PATHS = {"self_assessment", "harmonized_standard", "eucc", "notified_body"}
# Allowed paths per classification (CRITICAL must use notified_body)
ALLOWED_PATHS = {
"NOT_IN_SCOPE": set(),
"STANDARD": {"self_assessment", "harmonized_standard", "eucc", "notified_body"},
"IMPORTANT_I": {"self_assessment", "harmonized_standard", "eucc", "notified_body"},
"IMPORTANT_II": {"harmonized_standard", "eucc", "notified_body"},
"CRITICAL": {"notified_body"},
}
STATUS_WHITELIST = {
"draft", "scoped", "classified", "path_selected", "requirements_mapped",
"evidence_pending", "gaps_open", "remediation", "ready_for_review",
"declaration_ready", "post_market", "archived",
}
# =============================================================================
# REQUEST / RESPONSE MODELS
# =============================================================================
class CreateProjectRequest(BaseModel):
name: str
description: str = ""
gap_project_id: Optional[str] = None
class UpdateProjectRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
repo_url: Optional[str] = None
primary_language: Optional[str] = None
has_firmware: Optional[bool] = None
connected_to_internet: Optional[bool] = None
has_software_updates: Optional[bool] = None
processes_personal_data: Optional[bool] = None
is_critical_infra_supplier: Optional[bool] = None
intended_use: Optional[str] = None
status: Optional[str] = None
class PathSelectRequest(BaseModel):
conformity_path: str
# =============================================================================
# HELPERS
# =============================================================================
def _row_to_response(row) -> dict:
"""Convert a DB row to dict."""
return {
"id": str(row.id),
"tenant_id": row.tenant_id,
"name": row.name,
"description": row.description or "",
"gap_project_id": str(row.gap_project_id) if row.gap_project_id else None,
"repo_url": row.repo_url,
"primary_language": row.primary_language,
"has_firmware": bool(row.has_firmware),
"connected_to_internet": bool(row.connected_to_internet),
"has_software_updates": bool(row.has_software_updates),
"processes_personal_data": bool(row.processes_personal_data),
"is_critical_infra_supplier": bool(row.is_critical_infra_supplier),
"intended_use": row.intended_use or "",
"cra_classification": row.cra_classification,
"classification_rationale": (
row.classification_rationale
if isinstance(row.classification_rationale, list)
else json.loads(row.classification_rationale or "[]")
),
"conformity_path": row.conformity_path,
"status": row.status,
"created_at": row.created_at.isoformat() if row.created_at else "",
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
def _classify(intake: dict) -> tuple[str, list[str]]:
"""Deterministic Annex III/IV match. Returns (classification, rationale_list)."""
haystack = " ".join(
str(intake.get(f) or "").lower()
for f in ("intended_use", "primary_language", "name", "description")
)
def _match(buckets: list[str]) -> Optional[str]:
for token in buckets:
if token.lower() in haystack:
return token
return None
rationale: list[str] = []
hit = _match(ANNEX_IV_CRITICAL)
if hit:
rationale.append(f"Annex IV (Critical): Treffer auf '{hit}'")
return "CRITICAL", rationale
hit = _match(ANNEX_III_CLASS_II)
if hit:
rationale.append(f"Annex III Klasse II (Important_II): Treffer auf '{hit}'")
return "IMPORTANT_II", rationale
hit = _match(ANNEX_III_CLASS_I)
if hit:
rationale.append(f"Annex III Klasse I (Important_I): Treffer auf '{hit}'")
return "IMPORTANT_I", rationale
flags = [
("connected_to_internet", "Produkt ist mit dem Internet verbunden"),
("has_software_updates", "Produkt hat Software-Updates (digitales Element)"),
("processes_personal_data", "Produkt verarbeitet personenbezogene Daten"),
("is_critical_infra_supplier", "Produkt wird in kritischer Infrastruktur eingesetzt"),
]
for field, msg in flags:
if intake.get(field):
rationale.append(msg)
if rationale:
return "STANDARD", rationale
rationale.append("Kein digitales Element und keine Annex III/IV-Kategorie erkannt")
return "NOT_IN_SCOPE", rationale
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("")
async def list_projects(
tenant_id: str = Depends(get_tenant_id),
include_archived: bool = False,
):
"""List all CRA projects for the tenant."""
db = SessionLocal()
try:
if include_archived:
sql = """
SELECT * FROM compliance_cra_projects
WHERE tenant_id = :tenant_id
ORDER BY created_at DESC
"""
else:
sql = """
SELECT * FROM compliance_cra_projects
WHERE tenant_id = :tenant_id AND status != 'archived'
ORDER BY created_at DESC
"""
rows = db.execute(text(sql), {"tenant_id": tenant_id}).fetchall()
return {
"projects": [_row_to_response(row) for row in rows],
"total": len(rows),
}
finally:
db.close()
@router.post("", status_code=201)
async def create_project(
body: CreateProjectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Create a new CRA project. Optionally pre-populate intake from a gap_project."""
db = SessionLocal()
try:
gap_intake: dict = {}
if body.gap_project_id:
try:
gap_uuid = UUID(body.gap_project_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid gap_project_id (must be UUID)")
gap_row = db.execute(
text("""
SELECT connected_to_internet, has_software_updates,
processes_personal_data, is_critical_infra_supplier,
description
FROM gap_projects
WHERE id = :gid AND tenant_id = CAST(:tid AS uuid)
"""),
{"gid": str(gap_uuid), "tid": tenant_id},
).fetchone()
if gap_row:
gap_intake = {
"connected_to_internet": bool(gap_row.connected_to_internet),
"has_software_updates": bool(gap_row.has_software_updates),
"processes_personal_data": bool(gap_row.processes_personal_data),
"is_critical_infra_supplier": bool(gap_row.is_critical_infra_supplier),
"intended_use": gap_row.description or "",
}
result = db.execute(
text("""
INSERT INTO compliance_cra_projects
(tenant_id, name, description, gap_project_id,
connected_to_internet, has_software_updates,
processes_personal_data, is_critical_infra_supplier,
intended_use, status)
VALUES
(:tenant_id, :name, :description, :gap_project_id,
:connected_to_internet, :has_software_updates,
:processes_personal_data, :is_critical_infra_supplier,
:intended_use, 'draft')
RETURNING *
"""),
{
"tenant_id": tenant_id,
"name": body.name,
"description": body.description,
"gap_project_id": body.gap_project_id,
"connected_to_internet": gap_intake.get("connected_to_internet", False),
"has_software_updates": gap_intake.get("has_software_updates", False),
"processes_personal_data": gap_intake.get("processes_personal_data", False),
"is_critical_infra_supplier": gap_intake.get("is_critical_infra_supplier", False),
"intended_use": gap_intake.get("intended_use", ""),
},
)
row = result.fetchone()
db.commit()
logger.info("Created CRA project %s for tenant %s", row.id, tenant_id)
return _row_to_response(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}")
async def get_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Get a single CRA project (tenant-scoped)."""
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT * FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="CRA project not found")
return _row_to_response(row)
finally:
db.close()
@router.patch("/{project_id}")
async def update_project(
project_id: str,
body: UpdateProjectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Update intake / status fields. Status is whitelist-validated (no transitions)."""
db = SessionLocal()
try:
updates: dict = {"pid": project_id, "tid": tenant_id}
set_parts = ["updated_at = NOW()"]
for field in (
"name", "description", "repo_url", "primary_language",
"has_firmware", "connected_to_internet", "has_software_updates",
"processes_personal_data", "is_critical_infra_supplier",
"intended_use", "status",
):
val = getattr(body, field)
if val is None:
continue
if field == "status" and val not in STATUS_WHITELIST:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{val}'. Allowed: {sorted(STATUS_WHITELIST)}",
)
set_parts.append(f"{field} = :{field}")
updates[field] = val
if len(set_parts) == 1:
raise HTTPException(status_code=400, detail="No fields to update")
result = db.execute(
text(f"""
UPDATE compliance_cra_projects
SET {', '.join(set_parts)}
WHERE id = :pid AND tenant_id = :tid
RETURNING *
"""),
updates,
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="CRA project not found")
db.commit()
return _row_to_response(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.delete("/{project_id}")
async def archive_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Soft-archive a CRA project."""
db = SessionLocal()
try:
result = db.execute(
text("""
UPDATE compliance_cra_projects
SET status = 'archived', updated_at = NOW()
WHERE id = :pid AND tenant_id = :tid AND status != 'archived'
RETURNING id
"""),
{"pid": project_id, "tid": tenant_id},
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found or already archived")
db.commit()
return {"success": True, "id": str(row.id), "status": "archived"}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.post("/{project_id}/scope-check")
async def scope_check(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Run deterministic Annex III/IV classification on the project's intake fields.
Writes cra_classification + classification_rationale + status update.
"""
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT * FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="CRA project not found")
intake = {
"name": row.name,
"description": row.description,
"intended_use": row.intended_use,
"primary_language": row.primary_language,
"connected_to_internet": row.connected_to_internet,
"has_software_updates": row.has_software_updates,
"processes_personal_data": row.processes_personal_data,
"is_critical_infra_supplier": row.is_critical_infra_supplier,
}
classification, rationale = _classify(intake)
new_status = "scoped" if classification == "NOT_IN_SCOPE" else "classified"
updated = db.execute(
text("""
UPDATE compliance_cra_projects
SET cra_classification = :cls,
classification_rationale = CAST(:rat AS jsonb),
status = :status,
updated_at = NOW()
WHERE id = :pid AND tenant_id = :tid
RETURNING *
"""),
{
"pid": project_id,
"tid": tenant_id,
"cls": classification,
"rat": json.dumps(rationale, ensure_ascii=False),
"status": new_status,
},
).fetchone()
db.commit()
logger.info(
"Scope-check %s for tenant %s -> %s",
project_id, tenant_id, classification,
)
return _row_to_response(updated)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/requirements")
async def list_requirements(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Liste der 40 Annex-I-Requirements mit per-Projekt-Status.
Phase-2-Logik: Status default 'unbewertet' (kein evidence_check linked).
Phase-3 wird per-req Status aus compliance_evidence_check_results
berechnen.
"""
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT id, cra_classification FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="CRA project not found")
items = []
for req in ANNEX_I_REQUIREMENTS:
items.append({
**req,
"status": "unbewertet",
"mapped_measure_names": [
{"id": m, "name": MEASURES.get(m, m)} for m in req["mapped_measures"]
],
})
return {
"project_id": project_id,
"classification": row.cra_classification,
"total": len(items),
"items": items,
}
finally:
db.close()
@router.get("/{project_id}/backlog")
async def get_backlog(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Priorisierte TODO-Liste — gleiche Daten wie /requirements, aber sortiert.
Sortierung: severity_weight (desc), effort_days (asc), n (asc).
Deadline-Druck: Tage bis 11.12.2027 (CRA-CE-Pflicht).
"""
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT id, cra_classification FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="CRA project not found")
ce_deadline = date(2027, 12, 11)
today = date.today()
days_to_ce = max(0, (ce_deadline - today).days)
max_days = 365 * 3 # 3 Jahre als Skalierungsbasis
items = []
for req in ANNEX_I_REQUIREMENTS:
severity_w = SEVERITY_WEIGHT.get(req["severity"], 0)
deadline_pressure = 1.0 - min(days_to_ce / max_days, 1.0) # 0..1
effort_factor = 1.0 / max(req["effort_days"], 1)
priority_score = (
severity_w * (1.0 + 0.5 * deadline_pressure) * (1.0 + 0.1 * effort_factor)
)
items.append({
"req_id": req["req_id"],
"title": req["title"],
"category": req["category"],
"severity": req["severity"],
"annex_anchor": req["annex_anchor"],
"description": req["description"],
"effort_days": req["effort_days"],
"mapped_measure_names": [
{"id": m, "name": MEASURES.get(m, m)} for m in req["mapped_measures"]
],
"status": "unbewertet",
"priority_score": round(priority_score, 1),
})
items.sort(key=lambda x: (-x["priority_score"], x["effort_days"]))
for idx, it in enumerate(items, 1):
it["rank"] = idx
return {
"project_id": project_id,
"classification": row.cra_classification,
"days_to_ce_deadline": days_to_ce,
"deadlines": DEADLINES,
"total": len(items),
"items": items,
}
finally:
db.close()
@router.post("/{project_id}/path-select")
async def path_select(
project_id: str,
body: PathSelectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Set conformity_path. Validates path is allowed for the project's classification."""
if body.conformity_path not in CONFORMITY_PATHS:
raise HTTPException(
status_code=400,
detail=f"Invalid conformity_path. Allowed: {sorted(CONFORMITY_PATHS)}",
)
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT cra_classification FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="CRA project not found")
if not row.cra_classification:
raise HTTPException(
status_code=400,
detail="Run scope-check before selecting a conformity path",
)
allowed = ALLOWED_PATHS.get(row.cra_classification, set())
if body.conformity_path not in allowed:
raise HTTPException(
status_code=400,
detail=(
f"Path '{body.conformity_path}' not allowed for classification "
f"'{row.cra_classification}'. Allowed: {sorted(allowed)}"
),
)
updated = db.execute(
text("""
UPDATE compliance_cra_projects
SET conformity_path = :path,
status = 'path_selected',
updated_at = NOW()
WHERE id = :pid AND tenant_id = :tid
RETURNING *
"""),
{"pid": project_id, "tid": tenant_id, "path": body.conformity_path},
).fetchone()
db.commit()
return _row_to_response(updated)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
# =============================================================================
# PHASE 3: SBOM Upload + Automated Checks
# =============================================================================
DEFAULT_CHECKS = [
{
"check_code": "cra_security_txt",
"title": "security.txt (RFC 9116)",
"description": "Pruefung ob /.well-known/security.txt existiert und Contact-Eintrag enthaelt",
"check_type": "url_probe",
"linked_req_ids": ["CRA-AI-35"], # CVD
},
{
"check_code": "cra_vuln_disclosure_url",
"title": "Vulnerability-Disclosure-Policy-URL",
"description": "Manuelle Pflege: URL zur CVD-Policy. Wird in DoC referenziert.",
"check_type": "manual_review",
"linked_req_ids": ["CRA-AI-35"],
},
{
"check_code": "cra_update_policy_url",
"title": "Update-Policy-URL",
"description": "URL zur dokumentierten Patch-/Update-Policy (Lifecycle, SLAs).",
"check_type": "manual_review",
"linked_req_ids": ["CRA-AI-28", "CRA-AI-31", "CRA-AI-34"],
},
{
"check_code": "cra_signed_updates",
"title": "Signierte Updates",
"description": "Bestaetigung dass Updates digital signiert ausgeliefert werden.",
"check_type": "manual_review",
"linked_req_ids": ["CRA-AI-29", "CRA-AI-30"],
},
{
"check_code": "cra_default_password_check",
"title": "Keine Default-Passwoerter",
"description": "Bestaetigung dass das Produkt keine Default-Passwoerter ausliefert.",
"check_type": "manual_review",
"linked_req_ids": ["CRA-AI-8"],
},
{
"check_code": "cra_tls_cert_check",
"title": "TLS-Zertifikat des Produkt-Endpoints",
"description": "Pruefung ob TLS 1.2+ und gueltiges Zertifikat.",
"check_type": "tls_probe",
"linked_req_ids": ["CRA-AI-15"],
},
]
class CheckRunRequest(BaseModel):
target_url: Optional[str] = None
def _cra_project_exists(db, project_id: str, tenant_id: str) -> bool:
row = db.execute(
text("""
SELECT 1 FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
return bool(row)
@router.post("/{project_id}/sbom/upload")
async def upload_sbom(
project_id: str,
file: UploadFile = File(...),
tenant_id: str = Depends(get_tenant_id),
):
"""Upload CycloneDX or SPDX SBOM (JSON). Persists in compliance_cra_sboms."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
raw = await file.read()
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
# Format detection
fmt = None
spec_version = None
component_count = 0
summary: dict = {}
if isinstance(data, dict) and data.get("bomFormat") == "CycloneDX":
fmt = "cyclonedx"
spec_version = data.get("specVersion") or data.get("version")
components = data.get("components") or []
component_count = len(components)
summary = {
"bomFormat": "CycloneDX",
"specVersion": spec_version,
"serialNumber": data.get("serialNumber"),
"metadata_component": (data.get("metadata") or {}).get("component", {}),
"sample_components": components[:5],
}
elif isinstance(data, dict) and data.get("spdxVersion"):
fmt = "spdx"
spec_version = data["spdxVersion"]
packages = data.get("packages") or []
component_count = len(packages)
summary = {
"spdxVersion": spec_version,
"name": data.get("name"),
"sample_packages": [{"name": p.get("name"), "version": p.get("versionInfo")} for p in packages[:5]],
}
else:
raise HTTPException(
status_code=400,
detail="Unrecognized SBOM format. Expected CycloneDX (bomFormat='CycloneDX') or SPDX (spdxVersion present).",
)
result = db.execute(
text("""
INSERT INTO compliance_cra_sboms
(cra_project_id, tenant_id, filename, format, spec_version,
component_count, raw_content, summary, scan_status)
VALUES
(:pid, :tid, :fn, :fmt, :ver, :cnt,
CAST(:raw AS jsonb), CAST(:sum AS jsonb), 'pending')
RETURNING id, uploaded_at
"""),
{
"pid": project_id,
"tid": tenant_id,
"fn": file.filename or "unknown.json",
"fmt": fmt,
"ver": spec_version,
"cnt": component_count,
"raw": json.dumps(data),
"sum": json.dumps(summary),
},
).fetchone()
db.commit()
return {
"id": str(result.id),
"uploaded_at": result.uploaded_at.isoformat(),
"filename": file.filename,
"format": fmt,
"spec_version": spec_version,
"component_count": component_count,
"summary": summary,
"scan_status": "pending",
"note": "osv.dev scan wird durch separates Tool im Team durchgefuehrt.",
}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/sboms")
async def list_sboms(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""List all SBOM uploads for this project, newest first."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
rows = db.execute(
text("""
SELECT id, filename, format, spec_version, component_count,
summary, scan_status, scan_summary, uploaded_at, scanned_at
FROM compliance_cra_sboms
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY uploaded_at DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
items = []
for r in rows:
items.append({
"id": str(r.id),
"filename": r.filename,
"format": r.format,
"spec_version": r.spec_version,
"component_count": r.component_count,
"summary": r.summary if isinstance(r.summary, dict) else json.loads(r.summary or "{}"),
"scan_status": r.scan_status,
"scan_summary": r.scan_summary if isinstance(r.scan_summary, dict) else json.loads(r.scan_summary or "{}"),
"uploaded_at": r.uploaded_at.isoformat() if r.uploaded_at else None,
"scanned_at": r.scanned_at.isoformat() if r.scanned_at else None,
})
return {"project_id": project_id, "total": len(items), "items": items}
finally:
db.close()
@router.get("/{project_id}/checks")
async def list_checks(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""List configured checks + their latest run result."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
rows = db.execute(
text("""
SELECT id, check_code, title, description, check_type, target_url,
linked_control_ids, last_run_at, is_active
FROM compliance_evidence_checks
WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid_uuid
AND check_code LIKE 'cra_%'
ORDER BY check_code
"""),
{"tid": tenant_id, "pid_uuid": project_id},
).fetchall()
# Latest result per check
results: dict = {}
if rows:
ids = [str(r.id) for r in rows]
for r_row in db.execute(
text("""
SELECT check_id, status, message, ran_at
FROM compliance_evidence_check_results
WHERE check_id::text = ANY(:ids)
ORDER BY ran_at DESC
"""),
{"ids": ids},
).fetchall():
cid = str(r_row.check_id)
if cid not in results:
results[cid] = {
"status": r_row.status,
"message": r_row.message,
"ran_at": r_row.ran_at.isoformat() if r_row.ran_at else None,
}
items = []
for r in rows:
linked = r.linked_control_ids
if isinstance(linked, str):
try:
linked = json.loads(linked)
except Exception:
linked = []
items.append({
"id": str(r.id),
"check_code": r.check_code,
"title": r.title,
"description": r.description,
"check_type": r.check_type,
"target_url": r.target_url,
"linked_req_ids": linked or [],
"last_run_at": r.last_run_at.isoformat() if r.last_run_at else None,
"is_active": bool(r.is_active),
"latest_result": results.get(str(r.id)),
})
return {"project_id": project_id, "total": len(items), "items": items}
finally:
db.close()
@router.post("/{project_id}/checks/init")
async def init_checks(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Create the default set of CRA checks for this project (idempotent)."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
existing = db.execute(
text("""
SELECT check_code FROM compliance_evidence_checks
WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%'
"""),
{"tid": tenant_id, "pid": project_id},
).fetchall()
existing_codes = {r.check_code for r in existing}
created = 0
for c in DEFAULT_CHECKS:
if c["check_code"] in existing_codes:
continue
db.execute(
text("""
INSERT INTO compliance_evidence_checks
(tenant_id, project_id, check_code, title, description,
check_type, linked_control_ids, frequency, is_active)
VALUES
(CAST(:tid AS uuid), :pid, :code, :title, :desc,
:ctype, CAST(:linked AS jsonb), 'monthly', true)
"""),
{
"tid": tenant_id,
"pid": project_id,
"code": c["check_code"],
"title": c["title"],
"desc": c["description"],
"ctype": c["check_type"],
"linked": json.dumps(c["linked_req_ids"]),
},
)
created += 1
db.commit()
return {"created": created, "already_present": len(existing_codes)}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.post("/checks/{check_id}/run")
async def run_check(
check_id: str,
body: CheckRunRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Manually run a single check. Currently implements:
- cra_security_txt: HTTP GET, looks for 'Contact:' field
- cra_tls_cert_check: TLS handshake + cert info
- others: returns 'manual_review_required' status
"""
db = SessionLocal()
try:
check = db.execute(
text("""
SELECT id, check_code, target_url FROM compliance_evidence_checks
WHERE id = CAST(:cid AS uuid) AND tenant_id = CAST(:tid AS uuid)
"""),
{"cid": check_id, "tid": tenant_id},
).fetchone()
if not check:
raise HTTPException(status_code=404, detail="Check not found")
url = body.target_url or check.target_url
if url and url != check.target_url:
db.execute(
text("UPDATE compliance_evidence_checks SET target_url = :u WHERE id = CAST(:cid AS uuid)"),
{"u": url, "cid": check_id},
)
status = "manual_review_required"
message = "Dieser Check-Typ erfordert manuelle Pruefung."
if check.check_code == "cra_security_txt" and url:
base = url.rstrip("/")
probe_url = f"{base}/.well-known/security.txt"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
resp = await client.get(probe_url)
if resp.status_code != 200:
status = "fail"
message = f"GET {probe_url} -> HTTP {resp.status_code}"
else:
has_contact = any(
line.strip().lower().startswith("contact:")
for line in resp.text.splitlines()
)
if has_contact:
status = "pass"
message = f"security.txt vorhanden mit Contact-Eintrag ({len(resp.text)} bytes)"
else:
status = "fail"
message = "security.txt vorhanden, aber KEIN 'Contact:'-Eintrag"
except Exception as e:
status = "fail"
message = f"Fetch error: {e}"
elif check.check_code == "cra_tls_cert_check" and url:
try:
host = url.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0]
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
proto = ssock.version()
if proto and proto >= "TLSv1.2":
status = "pass"
message = f"TLS {proto} ok. Issuer: {dict(x[0] for x in cert.get('issuer', [])).get('organizationName', 'unknown')}"
else:
status = "fail"
message = f"TLS-Version zu alt: {proto}"
except Exception as e:
status = "fail"
message = f"TLS error: {e}"
# Persist result
db.execute(
text("""
INSERT INTO compliance_evidence_check_results
(check_id, tenant_id, project_id, status, message, ran_at)
VALUES
(CAST(:cid AS uuid), CAST(:tid AS uuid),
(SELECT project_id FROM compliance_evidence_checks WHERE id = CAST(:cid AS uuid)),
:status, :msg, NOW())
"""),
{"cid": check_id, "tid": tenant_id, "status": status, "msg": message},
)
db.execute(
text("""
UPDATE compliance_evidence_checks
SET last_run_at = NOW()
WHERE id = CAST(:cid AS uuid)
"""),
{"cid": check_id},
)
db.commit()
return {
"check_id": check_id,
"check_code": check.check_code,
"status": status,
"message": message,
"target_url": url,
}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
# =============================================================================
# PHASE 4: Vulnerability Disclosure + Post-Market Monitoring
# =============================================================================
VULN_STATUS_WHITELIST = {"reported", "triaged", "patched", "disclosed", "withdrawn"}
class CreateVulnRequest(BaseModel):
title: str
description: str = ""
cve_id: Optional[str] = None
severity: Optional[str] = None # LOW | MEDIUM | HIGH | CRITICAL
cvss_score: Optional[float] = None
affected_components: list[str] = []
reporter_source: str = "internal"
reporter_contact: Optional[str] = None
notes: str = ""
class UpdateVulnRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
cve_id: Optional[str] = None
severity: Optional[str] = None
cvss_score: Optional[float] = None
affected_components: Optional[list[str]] = None
reporter_source: Optional[str] = None
reporter_contact: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None
embargo_until: Optional[str] = None # ISO datetime
# Lifecycle timestamps — clients can set these explicitly or via status transition
triaged_at: Optional[str] = None
patched_at: Optional[str] = None
disclosed_at: Optional[str] = None
reported_to_enisa_at: Optional[str] = None
detailed_report_at: Optional[str] = None
def _vuln_to_dict(row) -> dict:
def _iso(v):
return v.isoformat() if v else None
components = row.affected_components
if isinstance(components, str):
try:
components = json.loads(components)
except Exception:
components = []
return {
"id": str(row.id),
"cra_project_id": str(row.cra_project_id),
"cve_id": row.cve_id,
"title": row.title,
"description": row.description or "",
"severity": row.severity,
"cvss_score": float(row.cvss_score) if row.cvss_score is not None else None,
"affected_components": components or [],
"reporter_source": row.reporter_source or "internal",
"reporter_contact": row.reporter_contact,
"discovered_at": _iso(row.discovered_at),
"triaged_at": _iso(row.triaged_at),
"patched_at": _iso(row.patched_at),
"disclosed_at": _iso(row.disclosed_at),
"embargo_until": _iso(row.embargo_until),
"reported_to_enisa_at": _iso(row.reported_to_enisa_at),
"detailed_report_at": _iso(row.detailed_report_at),
"status": row.status,
"notes": row.notes or "",
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
@router.post("/{project_id}/vulnerabilities", status_code=201)
async def create_vulnerability(
project_id: str,
body: CreateVulnRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Create a vulnerability record. discovered_at = now()."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
if body.severity and body.severity not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"):
raise HTTPException(status_code=400, detail="Invalid severity")
if body.cvss_score is not None and not (0.0 <= body.cvss_score <= 10.0):
raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0")
row = db.execute(
text("""
INSERT INTO compliance_cra_vulnerabilities
(cra_project_id, tenant_id, cve_id, title, description,
severity, cvss_score, affected_components,
reporter_source, reporter_contact, notes)
VALUES
(:pid, :tid, :cve, :title, :desc,
:sev, :cvss, CAST(:comp AS jsonb),
:src, :rcontact, :notes)
RETURNING *
"""),
{
"pid": project_id, "tid": tenant_id,
"cve": body.cve_id, "title": body.title, "desc": body.description,
"sev": body.severity, "cvss": body.cvss_score,
"comp": json.dumps(body.affected_components),
"src": body.reporter_source, "rcontact": body.reporter_contact,
"notes": body.notes,
},
).fetchone()
db.commit()
return _vuln_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/vulnerabilities")
async def list_vulnerabilities(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""List all vulnerabilities for this project, newest first."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
rows = db.execute(
text("""
SELECT * FROM compliance_cra_vulnerabilities
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY discovered_at DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
items = [_vuln_to_dict(r) for r in rows]
# Compliance summary
critical_open = sum(1 for v in items if v["severity"] == "CRITICAL" and v["status"] in ("reported", "triaged"))
breached_24h = 0
breached_72h = 0
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
for v in items:
if not v["discovered_at"]:
continue
disc = datetime.fromisoformat(v["discovered_at"])
age_hours = (now - disc).total_seconds() / 3600
if age_hours > 24 and not v["reported_to_enisa_at"]:
breached_24h += 1
if age_hours > 72 and not v["detailed_report_at"]:
breached_72h += 1
return {
"project_id": project_id,
"total": len(items),
"summary": {
"critical_open": critical_open,
"breached_24h_reporting": breached_24h,
"breached_72h_reporting": breached_72h,
"by_status": {s: sum(1 for v in items if v["status"] == s) for s in VULN_STATUS_WHITELIST},
},
"items": items,
}
finally:
db.close()
@router.patch("/vulnerabilities/{vuln_id}")
async def update_vulnerability(
vuln_id: str,
body: UpdateVulnRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Update vuln fields incl. status transition + lifecycle timestamps."""
from datetime import datetime
db = SessionLocal()
try:
updates: dict = {"vid": vuln_id, "tid": tenant_id}
set_parts = ["updated_at = NOW()"]
for field in (
"title", "description", "cve_id", "severity", "cvss_score",
"reporter_source", "reporter_contact", "notes", "status",
):
val = getattr(body, field)
if val is None:
continue
if field == "status" and val not in VULN_STATUS_WHITELIST:
raise HTTPException(status_code=400, detail=f"Invalid status. Allowed: {sorted(VULN_STATUS_WHITELIST)}")
if field == "severity" and val not in ("LOW", "MEDIUM", "HIGH", "CRITICAL"):
raise HTTPException(status_code=400, detail="Invalid severity")
if field == "cvss_score" and not (0.0 <= float(val) <= 10.0):
raise HTTPException(status_code=400, detail="cvss_score must be 0.0-10.0")
set_parts.append(f"{field} = :{field}")
updates[field] = val
if body.affected_components is not None:
set_parts.append("affected_components = CAST(:comp AS jsonb)")
updates["comp"] = json.dumps(body.affected_components)
# Auto-set timestamps on status transitions
# If client passes status='triaged' and triaged_at is None, set to NOW()
if body.status == "triaged" and not body.triaged_at:
set_parts.append("triaged_at = COALESCE(triaged_at, NOW())")
if body.status == "patched" and not body.patched_at:
set_parts.append("patched_at = COALESCE(patched_at, NOW())")
if body.status == "disclosed" and not body.disclosed_at:
set_parts.append("disclosed_at = COALESCE(disclosed_at, NOW())")
for ts_field in ("triaged_at", "patched_at", "disclosed_at",
"reported_to_enisa_at", "detailed_report_at", "embargo_until"):
val = getattr(body, ts_field)
if val is None:
continue
try:
datetime.fromisoformat(val.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid ISO datetime for {ts_field}")
set_parts.append(f"{ts_field} = :{ts_field}")
updates[ts_field] = val
if len(set_parts) == 1:
raise HTTPException(status_code=400, detail="No fields to update")
row = db.execute(
text(f"""
UPDATE compliance_cra_vulnerabilities
SET {', '.join(set_parts)}
WHERE id = :vid AND tenant_id = :tid
RETURNING *
"""),
updates,
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Vulnerability not found")
db.commit()
return _vuln_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.delete("/vulnerabilities/{vuln_id}")
async def delete_vulnerability(
vuln_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Mark vulnerability as withdrawn (soft delete)."""
db = SessionLocal()
try:
row = db.execute(
text("""
UPDATE compliance_cra_vulnerabilities
SET status = 'withdrawn', updated_at = NOW()
WHERE id = :vid AND tenant_id = :tid AND status != 'withdrawn'
RETURNING id
"""),
{"vid": vuln_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Vulnerability not found or already withdrawn")
db.commit()
return {"success": True, "id": str(row.id), "status": "withdrawn"}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/monitoring")
async def post_market_monitoring(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Combined Post-Market view: CRA timeline + vuln summary + checklist progress."""
from datetime import datetime, timezone
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
vulns = db.execute(
text("""
SELECT id, status, severity, discovered_at,
reported_to_enisa_at, detailed_report_at
FROM compliance_cra_vulnerabilities
WHERE cra_project_id = :pid AND tenant_id = :tid AND status != 'withdrawn'
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
sbom_count = db.execute(
text("SELECT count(*) FROM compliance_cra_sboms WHERE cra_project_id = :pid AND tenant_id = :tid"),
{"pid": project_id, "tid": tenant_id},
).scalar()
checks_count = db.execute(
text("""
SELECT count(*) FROM compliance_evidence_checks
WHERE tenant_id = CAST(:tid AS uuid) AND project_id = :pid AND check_code LIKE 'cra_%'
"""),
{"tid": tenant_id, "pid": project_id},
).scalar()
now = datetime.now(timezone.utc)
breached_24h = 0
breached_72h = 0
for v in vulns:
if not v.discovered_at:
continue
age = (now - v.discovered_at).total_seconds() / 3600
if age > 24 and not v.reported_to_enisa_at:
breached_24h += 1
if age > 72 and not v.detailed_report_at:
breached_72h += 1
checklist = [
{"item": "SBOM hochgeladen", "done": (sbom_count or 0) > 0,
"href_suffix": "sbom"},
{"item": "Automatisierte Checks konfiguriert", "done": (checks_count or 0) > 0,
"href_suffix": "checks"},
{"item": "Vulnerability-Tracking aktiv", "done": len(vulns) > 0,
"href_suffix": "vuln"},
{"item": "Keine 24h-Reporting-Pflichten ueberzogen", "done": breached_24h == 0,
"href_suffix": "vuln"},
{"item": "Keine 72h-Reporting-Pflichten ueberzogen", "done": breached_72h == 0,
"href_suffix": "vuln"},
]
return {
"project_id": project_id,
"deadlines": DEADLINES,
"summary": {
"active_vulns": len(vulns),
"critical_vulns": sum(1 for v in vulns if v.severity == "CRITICAL"),
"high_vulns": sum(1 for v in vulns if v.severity == "HIGH"),
"breached_24h_reporting": breached_24h,
"breached_72h_reporting": breached_72h,
"sbom_versions": sbom_count or 0,
"configured_checks": checks_count or 0,
},
"post_market_checklist": checklist,
}
finally:
db.close()
# =============================================================================
# PHASE 5: Document Generation (DoC, Technical Doc, CVD Policy, Update Policy, SBOM Report)
# =============================================================================
from .cra_doc_templates import DOC_GENERATORS, DOC_TYPE_LABELS # noqa: E402
DOC_STATUS_WHITELIST = {"draft", "reviewed", "approved", "superseded"}
class GenerateDocRequest(BaseModel):
doc_type: str
manufacturer: Optional[str] = None
notified_body: Optional[str] = None
security_contact: Optional[str] = None
class ApproveDocRequest(BaseModel):
signed_by: str
status: str = "approved"
def _doc_row_to_dict(row) -> dict:
return {
"id": str(row.id),
"cra_project_id": str(row.cra_project_id),
"doc_type": row.doc_type,
"doc_type_label": DOC_TYPE_LABELS.get(row.doc_type, row.doc_type),
"title": row.title,
"content_md": row.content_md,
"version": row.version,
"requirements_coverage": (
row.requirements_coverage
if isinstance(row.requirements_coverage, dict)
else json.loads(row.requirements_coverage or "{}")
),
"status": row.status,
"signed_by": row.signed_by,
"signed_at": row.signed_at.isoformat() if row.signed_at else None,
"generated_at": row.generated_at.isoformat() if row.generated_at else None,
"superseded_at": row.superseded_at.isoformat() if row.superseded_at else None,
}
@router.post("/{project_id}/documents/generate", status_code=201)
async def generate_document(
project_id: str,
body: GenerateDocRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Generate a document of the given type from current project state."""
if body.doc_type not in DOC_GENERATORS:
raise HTTPException(
status_code=400,
detail=f"Unknown doc_type. Allowed: {sorted(DOC_GENERATORS.keys())}",
)
db = SessionLocal()
try:
proj_row = db.execute(
text("""
SELECT * FROM compliance_cra_projects
WHERE id = :pid AND tenant_id = :tid
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if not proj_row:
raise HTTPException(status_code=404, detail="CRA project not found")
project = _row_to_response(proj_row)
# For SBOM report: fetch latest SBOM
latest_sbom = None
if body.doc_type == "doc_sbom_report":
sbom_row = db.execute(
text("""
SELECT id, filename, format, spec_version, component_count,
summary, scan_status, uploaded_at
FROM compliance_cra_sboms
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY uploaded_at DESC LIMIT 1
"""),
{"pid": project_id, "tid": tenant_id},
).fetchone()
if sbom_row:
latest_sbom = {
"filename": sbom_row.filename,
"format": sbom_row.format,
"spec_version": sbom_row.spec_version,
"component_count": sbom_row.component_count,
"summary": sbom_row.summary if isinstance(sbom_row.summary, dict) else json.loads(sbom_row.summary or "{}"),
"scan_status": sbom_row.scan_status,
"uploaded_at": sbom_row.uploaded_at.isoformat() if sbom_row.uploaded_at else None,
}
# Invoke generator
gen = DOC_GENERATORS[body.doc_type]
kwargs: dict = {}
if body.doc_type == "doc_eu_conformity":
kwargs = {"manufacturer": body.manufacturer, "notified_body": body.notified_body}
elif body.doc_type == "doc_cvd_policy":
kwargs = {"security_contact": body.security_contact}
elif body.doc_type == "doc_sbom_report":
kwargs = {"latest_sbom": latest_sbom}
title, content, coverage = gen(project, **kwargs)
# Supersede previous versions of this doc_type
db.execute(
text("""
UPDATE compliance_cra_documents
SET status = 'superseded', superseded_at = NOW()
WHERE cra_project_id = :pid AND tenant_id = :tid
AND doc_type = :dtype AND status != 'superseded'
"""),
{"pid": project_id, "tid": tenant_id, "dtype": body.doc_type},
)
# Next version number
next_ver = db.execute(
text("""
SELECT COALESCE(MAX(version), 0) + 1 FROM compliance_cra_documents
WHERE cra_project_id = :pid AND doc_type = :dtype
"""),
{"pid": project_id, "dtype": body.doc_type},
).scalar()
# Snapshot project context for audit
gen_context = {
"project_status": project.get("status"),
"classification": project.get("cra_classification"),
"conformity_path": project.get("conformity_path"),
"generated_for_version": next_ver,
}
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
(:pid, :tid, :dtype, :title, :content,
:ver, CAST(:cov AS jsonb), CAST(:ctx AS jsonb), 'draft')
RETURNING *
"""),
{
"pid": project_id, "tid": tenant_id,
"dtype": body.doc_type, "title": title, "content": content,
"ver": next_ver,
"cov": json.dumps(coverage),
"ctx": json.dumps(gen_context),
},
).fetchone()
db.commit()
return _doc_row_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}/documents")
async def list_documents(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
include_superseded: bool = False,
):
"""List documents for a project. By default only latest/active version per type."""
db = SessionLocal()
try:
if not _cra_project_exists(db, project_id, tenant_id):
raise HTTPException(status_code=404, detail="CRA project not found")
if include_superseded:
rows = db.execute(
text("""
SELECT * FROM compliance_cra_documents
WHERE cra_project_id = :pid AND tenant_id = :tid
ORDER BY doc_type, version DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
else:
rows = db.execute(
text("""
SELECT DISTINCT ON (doc_type) *
FROM compliance_cra_documents
WHERE cra_project_id = :pid AND tenant_id = :tid
AND status != 'superseded'
ORDER BY doc_type, version DESC
"""),
{"pid": project_id, "tid": tenant_id},
).fetchall()
# Show all doc types — even if not yet generated
existing_types = {r.doc_type for r in rows}
items = [_doc_row_to_dict(r) for r in rows]
for doc_type, label in DOC_TYPE_LABELS.items():
if doc_type not in existing_types:
items.append({
"id": None,
"cra_project_id": project_id,
"doc_type": doc_type,
"doc_type_label": label,
"title": label,
"content_md": None,
"version": 0,
"requirements_coverage": {},
"status": "not_generated",
"signed_by": None,
"signed_at": None,
"generated_at": None,
"superseded_at": None,
})
items.sort(key=lambda x: x["doc_type"])
return {"project_id": project_id, "total": len(items), "items": items}
finally:
db.close()
@router.get("/documents/{doc_id}")
async def get_document(
doc_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Get full document content (incl. content_md)."""
db = SessionLocal()
try:
row = db.execute(
text("""
SELECT * FROM compliance_cra_documents
WHERE id = :did AND tenant_id = :tid
"""),
{"did": doc_id, "tid": tenant_id},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Document not found")
return _doc_row_to_dict(row)
finally:
db.close()
@router.post("/documents/{doc_id}/approve")
async def approve_document(
doc_id: str,
body: ApproveDocRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Set status to reviewed/approved + signature."""
if body.status not in DOC_STATUS_WHITELIST:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Allowed: {sorted(DOC_STATUS_WHITELIST)}",
)
if not body.signed_by.strip():
raise HTTPException(status_code=400, detail="signed_by required")
db = SessionLocal()
try:
row = db.execute(
text("""
UPDATE compliance_cra_documents
SET status = :status, signed_by = :signer, signed_at = NOW()
WHERE id = :did AND tenant_id = :tid AND status != 'superseded'
RETURNING *
"""),
{"did": doc_id, "tid": tenant_id, "status": body.status, "signer": body.signed_by},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Document not found or already superseded")
db.commit()
return _doc_row_to_dict(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()