"""CRA <-> IACE link. A CRA project references its IACE (CE risk assessment) project — one assessment, two views: the IACE "CRA / Cyber" tab resolves the linked CRA project's latest assessment snapshot via this reference (instead of a demo fixture). Thin standalone router (cra_routes.py is over the LOC cap). """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import text from database import SessionLocal from .tenant_utils import get_tenant_id router = APIRouter(prefix="/v1/cra", tags=["cra"]) class LinkRequest(BaseModel): iace_project_id: str @router.post("/projects/{project_id}/link-iace") async def link_iace(project_id: str, body: LinkRequest, tenant_id: str = Depends(get_tenant_id)): db = SessionLocal() try: row = db.execute(text(""" UPDATE compliance_cra_projects SET linked_iace_project_id = CAST(:iace AS uuid), updated_at = NOW() WHERE id = CAST(:pid AS uuid) AND tenant_id = :tid RETURNING id """), {"iace": body.iace_project_id, "pid": project_id, "tid": tenant_id}).fetchone() db.commit() if not row: raise HTTPException(status_code=404, detail="CRA project not found") return {"cra_project_id": project_id, "linked_iace_project_id": body.iace_project_id} except HTTPException: raise except Exception: db.rollback() raise finally: db.close() @router.get("/by-iace/{iace_project_id}") async def by_iace(iace_project_id: str, tenant_id: str = Depends(get_tenant_id)): """The CRA project linked to this IACE project + its latest assessment snapshot.""" db = SessionLocal() try: proj = db.execute(text(""" SELECT id, name, cra_classification, status FROM compliance_cra_projects WHERE linked_iace_project_id = CAST(:iace AS uuid) AND tenant_id = :tid AND status != 'archived' ORDER BY created_at DESC LIMIT 1 """), {"iace": iace_project_id, "tid": tenant_id}).fetchone() if not proj: return {"linked": False} snap = db.execute(text(""" SELECT id, version, generation_context FROM compliance_cra_documents WHERE cra_project_id = CAST(:pid AS uuid) AND tenant_id = :tid AND doc_type = 'doc_risk_assessment' ORDER BY version DESC LIMIT 1 """), {"pid": str(proj.id), "tid": tenant_id}).fetchone() return { "linked": True, "cra_project": {"id": str(proj.id), "name": proj.name, "classification": proj.cra_classification, "status": proj.status}, "assessment": (snap.generation_context if snap else None), "snapshot_version": (snap.version if snap else None), } finally: db.close() class ScannerRepoMap(BaseModel): scanner_repo_id: str @router.get("/scanner-repo-map/{iace_project_id}") async def get_scanner_repo_map(iace_project_id: str, tenant_id: str = Depends(get_tenant_id)): """The scanner repo persisted for this IACE project (pull-flow), or empty.""" db = SessionLocal() try: row = db.execute(text(""" SELECT scanner_repo_id FROM compliance_cra_scanner_repo_map WHERE tenant_id = :tid AND iace_project_id = CAST(:p AS uuid) """), {"tid": tenant_id, "p": iace_project_id}).fetchone() return {"scanner_repo_id": row[0] if row else ""} finally: db.close() @router.put("/scanner-repo-map/{iace_project_id}") async def put_scanner_repo_map(iace_project_id: str, body: ScannerRepoMap, tenant_id: str = Depends(get_tenant_id)): """Upsert (or clear, on empty) the scanner repo for this IACE project.""" db = SessionLocal() try: if body.scanner_repo_id: db.execute(text(""" INSERT INTO compliance_cra_scanner_repo_map (tenant_id, iace_project_id, scanner_repo_id, updated_at) VALUES (:tid, CAST(:p AS uuid), :r, NOW()) ON CONFLICT (tenant_id, iace_project_id) DO UPDATE SET scanner_repo_id = EXCLUDED.scanner_repo_id, updated_at = NOW() """), {"tid": tenant_id, "p": iace_project_id, "r": body.scanner_repo_id}) else: db.execute(text(""" DELETE FROM compliance_cra_scanner_repo_map WHERE tenant_id = :tid AND iace_project_id = CAST(:p AS uuid) """), {"tid": tenant_id, "p": iace_project_id}) db.commit() return {"iace_project_id": iace_project_id, "scanner_repo_id": body.scanner_repo_id} except Exception: db.rollback() raise finally: db.close()