From 60f988f3cb0e4f668273a19f57e6ee9fde165fee Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 14 Jun 2026 19:22:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(cra):=20hard=20CRA<->IACE=20link=20?= =?UTF-8?q?=E2=80=94=20IACE=20tab=20pulls=20the=20linked=20assessment=20[m?= =?UTF-8?q?igration-approved]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 153 adds compliance_cra_projects.linked_iace_project_id (additive, idempotent). New thin router cra_link_routes.py: POST /projects/{id}/link-iace sets the reference; GET /by-iace/{iace_project_id} returns the linked CRA project + its latest assessment snapshot. The IACE "CRA / Cyber" tab now resolves the linked CRA assessment first (real, from the snapshot) and only falls back to the demo scenario when nothing is linked. One assessment, two views. [migration-approved] — user approved the new column for the CRA<->IACE reference. Co-Authored-By: Claude Opus 4.7 --- .../sdk/iace/[projectId]/cra/_hooks/useCRA.ts | 35 +++++++--- .../compliance/api/cra_link_routes.py | 70 +++++++++++++++++++ backend-compliance/main.py | 2 + .../migrations/153_cra_linked_iace.sql | 10 +++ 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 backend-compliance/compliance/api/cra_link_routes.py create mode 100644 backend-compliance/migrations/153_cra_linked_iace.sql diff --git a/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRA.ts b/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRA.ts index 040c4a83..b59ae334 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRA.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/cra/_hooks/useCRA.ts @@ -102,17 +102,36 @@ export function useCRA(projectId?: string) { useEffect(() => { let cancelled = false - fetch('/api/v1/cra/assess', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildPayload()), - }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) - .then((j) => { if (!cancelled) { setData(merge(j)); setLive(true) } }) - .catch((err) => { + ;(async () => { + // 1. Is a CRA project LINKED to this IACE project? Then show its real + // assessment (latest snapshot) instead of the demo scenario. + if (projectId) { + try { + const lr = await fetch(`/api/v1/cra/by-iace/${projectId}`) + if (lr.ok) { + const link = await lr.json() + if (link?.linked && link.assessment) { + if (!cancelled) { setData(merge(link.assessment)); setLive(true) } + return + } + } + } catch { /* fall through to the demo assess */ } + } + // 2. Fallback: live demo assess (scenario findings). + try { + const r = await fetch('/api/v1/cra/assess', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildPayload()), + }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const j = await r.json() + if (!cancelled) { setData(merge(j)); setLive(true) } + } catch (err) { console.error('CRA assess fetch failed, using static scenario:', err) if (!cancelled) { setData(DEMO_SCENARIO); setLive(false) } - }) + } + })() return () => { cancelled = true } - }, [buildPayload]) + }, [buildPayload, projectId]) const refreshSnapshots = useCallback(() => { if (!projectId) return diff --git a/backend-compliance/compliance/api/cra_link_routes.py b/backend-compliance/compliance/api/cra_link_routes.py new file mode 100644 index 00000000..f131731f --- /dev/null +++ b/backend-compliance/compliance/api/cra_link_routes.py @@ -0,0 +1,70 @@ +"""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() diff --git a/backend-compliance/main.py b/backend-compliance/main.py index b55ae452..40551645 100644 --- a/backend-compliance/main.py +++ b/backend-compliance/main.py @@ -57,6 +57,7 @@ from compliance.api.agent_migration_routes import router as agent_migration_rout from compliance.api.vendor_assessment_routes import router as vendor_assessment_router from compliance.api.cra_routes import router as cra_router from compliance.api.cra_assess_routes import router as cra_assess_router +from compliance.api.cra_link_routes import router as cra_link_router from compliance.api.quaidal_routes import router as quaidal_router # Middleware @@ -173,6 +174,7 @@ app.include_router(vendor_assessment_router, prefix="/api") # CRA (Cyber Resilience Act) Compliance app.include_router(cra_router, prefix="/api") app.include_router(cra_assess_router, prefix="/api") +app.include_router(cra_link_router, prefix="/api") app.include_router(quaidal_router, prefix="/api") diff --git a/backend-compliance/migrations/153_cra_linked_iace.sql b/backend-compliance/migrations/153_cra_linked_iace.sql new file mode 100644 index 00000000..f14e501c --- /dev/null +++ b/backend-compliance/migrations/153_cra_linked_iace.sql @@ -0,0 +1,10 @@ +-- Migration 153: CRA project -> IACE (CE risk assessment) project reference. +-- One assessment, two views: the IACE "CRA / Cyber" tab resolves the linked CRA +-- project's latest assessment snapshot via this column instead of a demo fixture. +-- Additive + idempotent. + +ALTER TABLE compliance_cra_projects + ADD COLUMN IF NOT EXISTS linked_iace_project_id UUID; + +CREATE INDEX IF NOT EXISTS idx_cra_projects_linked_iace + ON compliance_cra_projects (linked_iace_project_id);