diff --git a/control-pipeline/api/__init__.py b/control-pipeline/api/__init__.py index 4c16e93..94b22bf 100644 --- a/control-pipeline/api/__init__.py +++ b/control-pipeline/api/__init__.py @@ -9,6 +9,7 @@ from api.decision_trace_routes import router as decision_trace_router from api.decision_trace_routes import full_trace_router from api.compliance_commit_routes import router as compliance_commit_router from api.decision_event_routes import router as decision_event_router +from api.deployment_check_routes import router as deployment_check_router router = APIRouter() router.include_router(generator_router) @@ -20,3 +21,4 @@ router.include_router(decision_trace_router) router.include_router(full_trace_router) router.include_router(compliance_commit_router) router.include_router(decision_event_router) +router.include_router(deployment_check_router) diff --git a/control-pipeline/api/deployment_check_routes.py b/control-pipeline/api/deployment_check_routes.py new file mode 100644 index 0000000..0107f2c --- /dev/null +++ b/control-pipeline/api/deployment_check_routes.py @@ -0,0 +1,258 @@ +"""Pre-Deployment Enforcement API — G4. + +CI/CD gate: checks if a deployment is safe by evaluating the compliance +status of all affected controls. Blocks deploys with non-compliant controls. +""" + +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 + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1/deployment-checks", tags=["deployment-checks"]) + +SEVERITY_WEIGHT = { + "critical": 4.0, + "high": 3.0, + "medium": 2.0, + "low": 1.0, +} + + +class DeployCheckRequest(BaseModel): + tenant_id: str + commit_hash: str + branch: Optional[str] = None + environment: str = "production" + affected_control_ids: list[str] = [] + metadata: dict = {} + + +class OverrideRequest(BaseModel): + override_by: str + override_reason: str + + +@router.post("") +async def check_deployment(req: DeployCheckRequest): + """Check if a deployment is safe. Returns verdict: approved/blocked.""" + db = SessionLocal() + try: + check_id = str(uuid.uuid4()) + + blocking = [] + warnings = [] + risk_score = 0.0 + + if req.affected_control_ids: + # Look up latest decision status for each affected control + for ctrl_id in req.affected_control_ids: + row = db.execute(text(""" + SELECT dt.status, dt.decision_reason, dt.fix_strategy, + cc.control_id, cc.title, cc.severity + FROM decision_traces dt + JOIN canonical_controls cc ON cc.id = dt.control_uuid + WHERE cc.control_id = :cid + ORDER BY dt.decided_at DESC NULLS LAST + LIMIT 1 + """), {"cid": ctrl_id}).fetchone() + + if not row: + # No decision → treat as not_assessed (warning) + warnings.append({ + "control_id": ctrl_id, + "status": "not_assessed", + "reason": "No compliance decision recorded", + }) + continue + + status = row[0] + severity = row[5] or "medium" + weight = SEVERITY_WEIGHT.get(severity, 2.0) + + if status in ("not_compliant", "under_remediation"): + blocking.append({ + "control_id": row[3], + "title": row[4], + "status": status, + "reason": row[1], + "fix_strategy": row[2], + "severity": severity, + }) + risk_score += weight + elif status == "partially_compliant": + warnings.append({ + "control_id": row[3], + "title": row[4], + "status": status, + "reason": row[1], + "severity": severity, + }) + risk_score += weight * 0.5 + + # Also check for open failure events (G3) + if req.affected_control_ids: + placeholders = ",".join(["'%s'" % c for c in req.affected_control_ids]) + open_failures = db.execute(text(f""" + SELECT cc.control_id, de.summary + FROM decision_events de + JOIN canonical_controls cc ON cc.id = de.control_uuid + WHERE cc.control_id IN ({placeholders}) + AND de.event_type = 'failure' + AND de.created_at > NOW() - interval '30 days' + AND NOT EXISTS ( + SELECT 1 FROM decision_events de2 + WHERE de2.control_uuid = de.control_uuid + AND de2.event_type = 'verification' + AND de2.created_at > de.created_at + ) + """)).fetchall() + + for f in open_failures: + if not any(b["control_id"] == f[0] for b in blocking): + blocking.append({ + "control_id": f[0], + "status": "open_failure", + "reason": f[1] or "Unresolved failure event", + "severity": "high", + }) + risk_score += 3.0 + + verdict = "approved" if not blocking else "blocked" + summary = ( + f"{len(blocking)} blocking, {len(warnings)} warnings. " + + ("Deploy approved." if verdict == "approved" + else f"Fix {', '.join(b['control_id'] for b in blocking)} before deploying.") + ) + + # Store check result + db.execute(text(""" + INSERT INTO deployment_checks + (id, tenant_id, commit_hash, branch, environment, + verdict, affected_control_ids, blocking_controls, + warning_controls, risk_score, summary, metadata) + VALUES + (CAST(:id AS uuid), CAST(:tid AS uuid), :hash, :branch, :env, + :verdict, CAST(:affected AS jsonb), CAST(:blocking AS jsonb), + CAST(:warnings AS jsonb), :risk, :summary, CAST(:meta AS jsonb)) + """), { + "id": check_id, + "tid": req.tenant_id, + "hash": req.commit_hash, + "branch": req.branch, + "env": req.environment, + "verdict": verdict, + "affected": json.dumps(req.affected_control_ids), + "blocking": json.dumps(blocking), + "warnings": json.dumps(warnings), + "risk": risk_score, + "summary": summary, + "meta": json.dumps(req.metadata), + }) + db.commit() + + return { + "id": check_id, + "verdict": verdict, + "risk_score": risk_score, + "blocking_controls": blocking, + "warning_controls": warnings, + "summary": summary, + } + finally: + db.close() + + +@router.get("/stats") +async def check_stats(tenant_id: Optional[str] = None): + """Deployment check statistics.""" + db = SessionLocal() + try: + tf = "" + params: dict = {} + if tenant_id: + tf = "WHERE tenant_id = CAST(:tid AS uuid)" + params["tid"] = tenant_id + + by_verdict = db.execute(text(f""" + SELECT verdict, count(*) FROM deployment_checks {tf} + GROUP BY verdict + """), params).fetchall() + + total = sum(r[1] for r in by_verdict) + verdicts = {r[0]: r[1] for r in by_verdict} + + return { + "total_checks": total, + "by_verdict": verdicts, + "approval_rate": round( + verdicts.get("approved", 0) / total * 100, 1 + ) if total > 0 else 0, + "override_count": verdicts.get("override", 0), + } + finally: + db.close() + + +@router.post("/{check_id}/override") +async def override_check(check_id: str, req: OverrideRequest): + """Override a blocked deployment (with justification).""" + db = SessionLocal() + try: + result = db.execute(text(""" + UPDATE deployment_checks + SET verdict = 'override', override_by = :by, override_reason = :reason + WHERE id = CAST(:id AS uuid) AND verdict = 'blocked' + """), { + "id": check_id, + "by": req.override_by, + "reason": req.override_reason, + }) + db.commit() + + if result.rowcount == 0: + raise HTTPException(status_code=404, detail="Check not found or not blocked") + + return {"id": check_id, "verdict": "override", "override_by": req.override_by} + finally: + db.close() + + +@router.get("/{check_id}") +async def get_check(check_id: str): + """Get details of a deployment check.""" + db = SessionLocal() + try: + row = db.execute(text(""" + SELECT * FROM deployment_checks WHERE id = CAST(:id AS uuid) + """), {"id": check_id}).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Check not found") + + return { + "id": str(row.id), + "tenant_id": str(row.tenant_id), + "commit_hash": row.commit_hash, + "branch": row.branch, + "environment": row.environment, + "verdict": row.verdict, + "affected_control_ids": row.affected_control_ids, + "blocking_controls": row.blocking_controls, + "warning_controls": row.warning_controls, + "risk_score": float(row.risk_score), + "override_by": row.override_by, + "override_reason": row.override_reason, + "summary": row.summary, + "created_at": str(row.created_at), + } + finally: + db.close() diff --git a/control-pipeline/migrations/009_deployment_checks.sql b/control-pipeline/migrations/009_deployment_checks.sql new file mode 100644 index 0000000..1c39e78 --- /dev/null +++ b/control-pipeline/migrations/009_deployment_checks.sql @@ -0,0 +1,38 @@ +-- Migration 009: Deployment Checks / Pre-Deployment Enforcement (G4) +-- Schema: compliance +-- Run: ssh macmini "docker exec -i bp-core-postgres psql -U breakpilot -d breakpilot_db" < control-pipeline/migrations/009_deployment_checks.sql + +SET search_path TO compliance, public; + +CREATE TABLE IF NOT EXISTS deployment_checks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Deploy Info + commit_hash VARCHAR(64) NOT NULL, + branch VARCHAR(200), + environment VARCHAR(50) DEFAULT 'production', + + -- Result + verdict VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (verdict IN ('pending', 'approved', 'blocked', 'override')), + + -- Impact + affected_control_ids JSONB DEFAULT '[]', + blocking_controls JSONB DEFAULT '[]', + warning_controls JSONB DEFAULT '[]', + risk_score NUMERIC(5,2) DEFAULT 0.0, + + -- Override + override_by VARCHAR(200), + override_reason TEXT, + + summary TEXT, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_dc_tenant ON deployment_checks(tenant_id); +CREATE INDEX IF NOT EXISTS idx_dc_hash ON deployment_checks(commit_hash); +CREATE INDEX IF NOT EXISTS idx_dc_verdict ON deployment_checks(verdict); +CREATE INDEX IF NOT EXISTS idx_dc_created ON deployment_checks(created_at);