Core engine (dependency_engine.py): - 5 dependency types: prerequisite, supersedes, compensating_control, conditional_requirement, scope_exclusion - Generic condition evaluator (JSONB rules with AND/OR/NOT/field ops) - Priority-based conflict resolution - Cycle detection (DFS) + topological sort - Full evaluation with MCP-compatible dependency_resolution trace - 39 tests all passing (incl. GHV scenario from user requirements) Automatic generator (dependency_generator.py): - Ontology-based: same normalized_object + phase sequence -> prerequisite - Pattern-based: define->implement, implement->monitor, etc. - Domain packs: YAML rules for GDPR, AI Act, CRA, Security, Labor Contracts - 14 tests all passing API routes (dependency_routes.py): - CRUD for dependencies - POST /evaluate with dependency resolution - POST /generate (auto-generation with dry_run) - POST /validate (cycle detection) - GET /graph (nodes + edges for visualization) Prompt enhancement (decomposition_pass.py): - Added dependency_hints + lifecycle_phase_order to Pass 0b prompt - Stored in generation_metadata for post-processing DB migration: control_dependencies + control_evaluation_results tables 126 tests total, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
449 lines
15 KiB
Python
449 lines
15 KiB
Python
"""
|
|
FastAPI routes for the Control Dependency Engine.
|
|
|
|
Endpoints:
|
|
GET /v1/dependencies — List dependencies
|
|
POST /v1/dependencies — Create a dependency
|
|
DELETE /v1/dependencies/{dep_id} — Deactivate a dependency
|
|
POST /v1/dependencies/generate — Auto-generate dependencies
|
|
POST /v1/dependencies/evaluate — Evaluate controls with dependencies
|
|
GET /v1/dependencies/evaluate/{run_id} — Get evaluation results
|
|
POST /v1/dependencies/validate — Validate graph (cycle check)
|
|
GET /v1/dependencies/graph — Dependency graph for visualization
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
|
|
from db.session import SessionLocal
|
|
from services.dependency_engine import (
|
|
Dependency,
|
|
ControlState,
|
|
EvaluationResult,
|
|
evaluate_controls,
|
|
detect_cycles,
|
|
load_all_active_dependencies,
|
|
load_dependencies_for_controls,
|
|
store_dependency,
|
|
store_evaluation_results,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/v1/dependencies", tags=["dependencies"])
|
|
|
|
|
|
# =============================================================================
|
|
# REQUEST / RESPONSE MODELS
|
|
# =============================================================================
|
|
|
|
class DependencyCreateRequest(BaseModel):
|
|
source_control_id: str
|
|
target_control_id: str
|
|
dependency_type: str
|
|
condition: dict = {}
|
|
effect: dict = {}
|
|
priority: int = 100
|
|
|
|
|
|
class EvaluateRequest(BaseModel):
|
|
control_ids: Optional[list] = None
|
|
company_profile: dict = {}
|
|
control_statuses: dict = {}
|
|
|
|
|
|
class GenerateRequest(BaseModel):
|
|
enable_ontology: bool = True
|
|
enable_patterns: bool = True
|
|
enable_domain_packs: bool = True
|
|
dry_run: bool = True
|
|
limit: int = 0
|
|
|
|
|
|
# =============================================================================
|
|
# LIST DEPENDENCIES
|
|
# =============================================================================
|
|
|
|
@router.get("/")
|
|
async def list_dependencies(
|
|
dependency_type: Optional[str] = Query(default=None),
|
|
source_id: Optional[str] = Query(default=None),
|
|
target_id: Optional[str] = Query(default=None),
|
|
active_only: bool = Query(default=True),
|
|
limit: int = Query(default=100, le=1000),
|
|
offset: int = Query(default=0),
|
|
):
|
|
"""List dependencies with optional filters."""
|
|
db = SessionLocal()
|
|
try:
|
|
conditions = []
|
|
params: dict = {"lim": limit, "off": offset}
|
|
|
|
if active_only:
|
|
conditions.append("is_active = TRUE")
|
|
if dependency_type:
|
|
conditions.append("dependency_type = :dtype")
|
|
params["dtype"] = dependency_type
|
|
if source_id:
|
|
conditions.append("source_control_id = CAST(:src AS uuid)")
|
|
params["src"] = source_id
|
|
if target_id:
|
|
conditions.append("target_control_id = CAST(:tgt AS uuid)")
|
|
params["tgt"] = target_id
|
|
|
|
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
|
|
rows = db.execute(text(f"""
|
|
SELECT d.id, d.source_control_id, d.target_control_id,
|
|
d.dependency_type, d.condition, d.effect, d.priority,
|
|
d.generation_method, d.is_active, d.created_at,
|
|
s.control_id AS source_cid, s.title AS source_title,
|
|
t.control_id AS target_cid, t.title AS target_title
|
|
FROM control_dependencies d
|
|
LEFT JOIN canonical_controls s ON s.id = d.source_control_id
|
|
LEFT JOIN canonical_controls t ON t.id = d.target_control_id
|
|
{where}
|
|
ORDER BY d.priority, d.created_at
|
|
LIMIT :lim OFFSET :off
|
|
"""), params).fetchall()
|
|
|
|
total = db.execute(text(f"""
|
|
SELECT COUNT(*) FROM control_dependencies d {where}
|
|
"""), params).scalar()
|
|
|
|
return {
|
|
"total": total,
|
|
"dependencies": [
|
|
{
|
|
"id": str(r[0]),
|
|
"source_control_id": str(r[1]),
|
|
"target_control_id": str(r[2]),
|
|
"dependency_type": r[3],
|
|
"condition": r[4],
|
|
"effect": r[5],
|
|
"priority": r[6],
|
|
"generation_method": r[7],
|
|
"is_active": r[8],
|
|
"created_at": str(r[9]) if r[9] else None,
|
|
"source_control_id_short": r[10] or "",
|
|
"source_title": r[11] or "",
|
|
"target_control_id_short": r[12] or "",
|
|
"target_title": r[13] or "",
|
|
}
|
|
for r in rows
|
|
],
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# CREATE DEPENDENCY
|
|
# =============================================================================
|
|
|
|
@router.post("/")
|
|
async def create_dependency(req: DependencyCreateRequest):
|
|
"""Create a manual dependency."""
|
|
valid_types = {"prerequisite", "conditional_requirement", "supersedes",
|
|
"compensating_control", "scope_exclusion"}
|
|
if req.dependency_type not in valid_types:
|
|
raise HTTPException(400, f"Invalid type. Must be one of: {valid_types}")
|
|
|
|
if req.source_control_id == req.target_control_id:
|
|
raise HTTPException(400, "Source and target must be different")
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
dep = Dependency(
|
|
source_control_id=req.source_control_id,
|
|
target_control_id=req.target_control_id,
|
|
dependency_type=req.dependency_type,
|
|
condition=req.condition,
|
|
effect=req.effect,
|
|
priority=req.priority,
|
|
generation_method="manual",
|
|
)
|
|
dep_id = store_dependency(db, dep)
|
|
db.commit()
|
|
return {"id": dep_id, "status": "created"}
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(500, str(e))
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# DELETE (DEACTIVATE) DEPENDENCY
|
|
# =============================================================================
|
|
|
|
@router.delete("/{dep_id}")
|
|
async def deactivate_dependency(dep_id: str):
|
|
"""Deactivate a dependency (soft delete)."""
|
|
db = SessionLocal()
|
|
try:
|
|
result = db.execute(
|
|
text("UPDATE control_dependencies SET is_active = FALSE, updated_at = NOW() WHERE id = CAST(:did AS uuid)"),
|
|
{"did": dep_id},
|
|
)
|
|
db.commit()
|
|
if result.rowcount == 0:
|
|
raise HTTPException(404, "Dependency not found")
|
|
return {"status": "deactivated"}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# AUTO-GENERATE DEPENDENCIES
|
|
# =============================================================================
|
|
|
|
@router.post("/generate")
|
|
async def generate_dependencies(req: GenerateRequest):
|
|
"""Auto-generate dependencies from ontology, patterns, and domain packs."""
|
|
from services.dependency_generator import generate_all_dependencies
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
query = """
|
|
SELECT id::text, control_id, title, generation_metadata
|
|
FROM canonical_controls
|
|
WHERE release_state = 'draft'
|
|
AND generation_metadata->>'decomposition_method' = 'pass0b'
|
|
"""
|
|
if req.limit > 0:
|
|
query += f" LIMIT {req.limit}"
|
|
|
|
rows = db.execute(text(query)).fetchall()
|
|
controls = []
|
|
for r in rows:
|
|
meta = r[3] if isinstance(r[3], dict) else {}
|
|
controls.append({
|
|
"id": r[0],
|
|
"control_id": r[1],
|
|
"title": r[2],
|
|
"generation_metadata": meta,
|
|
})
|
|
|
|
deps, stats = generate_all_dependencies(
|
|
controls,
|
|
enable_ontology=req.enable_ontology,
|
|
enable_patterns=req.enable_patterns,
|
|
enable_domain_packs=req.enable_domain_packs,
|
|
)
|
|
|
|
if not req.dry_run:
|
|
stored = 0
|
|
for dep in deps:
|
|
store_dependency(db, dep)
|
|
stored += 1
|
|
db.commit()
|
|
stats["stored"] = stored
|
|
|
|
return {
|
|
"dry_run": req.dry_run,
|
|
"controls_analyzed": len(controls),
|
|
"stats": stats,
|
|
"sample_dependencies": [
|
|
{
|
|
"source": d.source_control_id[:8],
|
|
"target": d.target_control_id[:8],
|
|
"type": d.dependency_type,
|
|
"method": d.generation_method,
|
|
"priority": d.priority,
|
|
}
|
|
for d in deps[:20]
|
|
],
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# EVALUATE CONTROLS
|
|
# =============================================================================
|
|
|
|
@router.post("/evaluate")
|
|
async def evaluate(req: EvaluateRequest):
|
|
"""Evaluate controls with dependency resolution."""
|
|
db = SessionLocal()
|
|
try:
|
|
# Load control statuses
|
|
if req.control_statuses:
|
|
control_ids = list(req.control_statuses.keys())
|
|
states = {
|
|
cid: ControlState(control_id=cid, raw_status=status)
|
|
for cid, status in req.control_statuses.items()
|
|
}
|
|
elif req.control_ids:
|
|
control_ids = req.control_ids
|
|
states = {
|
|
cid: ControlState(control_id=cid, raw_status="fail")
|
|
for cid in control_ids
|
|
}
|
|
else:
|
|
raise HTTPException(400, "Provide control_ids or control_statuses")
|
|
|
|
# Load dependencies
|
|
deps = load_dependencies_for_controls(db, control_ids)
|
|
|
|
# Evaluate
|
|
results = evaluate_controls(states, deps, req.company_profile)
|
|
|
|
# Store results
|
|
store_evaluation_results(db, results, req.company_profile)
|
|
db.commit()
|
|
|
|
# Format response
|
|
run_id = next(iter(results.values())).evaluation_run_id if results else ""
|
|
|
|
return {
|
|
"evaluation_run_id": run_id,
|
|
"total_controls": len(results),
|
|
"dependencies_evaluated": len(deps),
|
|
"results": [
|
|
{
|
|
"control_id": r.control_id,
|
|
"raw_status": r.raw_status,
|
|
"resolved_status": r.resolved_status,
|
|
"dependency_resolution": r.dependency_resolution,
|
|
"confidence": r.confidence,
|
|
}
|
|
for r in results.values()
|
|
],
|
|
"summary": {
|
|
"pass": sum(1 for r in results.values() if r.resolved_status == "pass"),
|
|
"fail": sum(1 for r in results.values() if r.resolved_status == "fail"),
|
|
"not_applicable": sum(1 for r in results.values() if r.resolved_status == "not_applicable"),
|
|
"compensated_fail": sum(1 for r in results.values() if r.resolved_status == "compensated_fail"),
|
|
"review_required": sum(1 for r in results.values() if r.resolved_status == "review_required"),
|
|
},
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# GET EVALUATION RESULTS
|
|
# =============================================================================
|
|
|
|
@router.get("/evaluate/{run_id}")
|
|
async def get_evaluation_results(run_id: str):
|
|
"""Get stored evaluation results for a run."""
|
|
db = SessionLocal()
|
|
try:
|
|
rows = db.execute(text("""
|
|
SELECT er.control_id::text, cc.control_id, cc.title,
|
|
er.raw_status, er.resolved_status,
|
|
er.dependency_resolution, er.confidence, er.reasoning
|
|
FROM control_evaluation_results er
|
|
JOIN canonical_controls cc ON cc.id = er.control_id
|
|
WHERE er.evaluation_run_id = CAST(:rid AS uuid)
|
|
ORDER BY er.resolved_status, cc.control_id
|
|
"""), {"rid": run_id}).fetchall()
|
|
|
|
if not rows:
|
|
raise HTTPException(404, "Evaluation run not found")
|
|
|
|
return {
|
|
"evaluation_run_id": run_id,
|
|
"total": len(rows),
|
|
"results": [
|
|
{
|
|
"control_uuid": r[0],
|
|
"control_id": r[1],
|
|
"title": r[2],
|
|
"raw_status": r[3],
|
|
"resolved_status": r[4],
|
|
"dependency_resolution": r[5],
|
|
"confidence": r[6],
|
|
"reasoning": r[7],
|
|
}
|
|
for r in rows
|
|
],
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# VALIDATE GRAPH (CYCLE CHECK)
|
|
# =============================================================================
|
|
|
|
@router.post("/validate")
|
|
async def validate_graph():
|
|
"""Validate the dependency graph for cycles."""
|
|
db = SessionLocal()
|
|
try:
|
|
deps = load_all_active_dependencies(db)
|
|
cycles = detect_cycles(deps)
|
|
|
|
return {
|
|
"total_dependencies": len(deps),
|
|
"cycles_found": len(cycles),
|
|
"cycles": cycles[:20],
|
|
"is_valid": len(cycles) == 0,
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
# =============================================================================
|
|
# DEPENDENCY GRAPH (FOR VISUALIZATION)
|
|
# =============================================================================
|
|
|
|
@router.get("/graph")
|
|
async def get_graph(limit: int = Query(default=200, le=1000)):
|
|
"""Get dependency graph as nodes + edges."""
|
|
db = SessionLocal()
|
|
try:
|
|
deps = load_all_active_dependencies(db)[:limit]
|
|
|
|
node_ids = set()
|
|
for d in deps:
|
|
node_ids.add(d.source_control_id)
|
|
node_ids.add(d.target_control_id)
|
|
|
|
nodes = []
|
|
if node_ids:
|
|
id_list = list(node_ids)
|
|
rows = db.execute(text("""
|
|
SELECT id::text, control_id, title, release_state, category
|
|
FROM canonical_controls
|
|
WHERE id = ANY(CAST(:ids AS uuid[]))
|
|
"""), {"ids": id_list}).fetchall()
|
|
|
|
for r in rows:
|
|
nodes.append({
|
|
"id": r[0],
|
|
"control_id": r[1],
|
|
"title": r[2],
|
|
"release_state": r[3],
|
|
"category": r[4],
|
|
})
|
|
|
|
edges = [
|
|
{
|
|
"source": d.source_control_id,
|
|
"target": d.target_control_id,
|
|
"type": d.dependency_type,
|
|
"priority": d.priority,
|
|
"generation_method": d.generation_method,
|
|
}
|
|
for d in deps
|
|
]
|
|
|
|
return {
|
|
"nodes": nodes,
|
|
"edges": edges,
|
|
"total_nodes": len(nodes),
|
|
"total_edges": len(edges),
|
|
}
|
|
finally:
|
|
db.close()
|