feat(pipeline): G4 Pre-Deployment Enforcement — CI/CD compliance gate

New table: deployment_checks (verdict, blocking/warning controls, risk score)
New API:
  POST /v1/deployment-checks (SDK asks: "can I deploy?")
  GET /v1/deployment-checks/{id} (check result)
  POST /v1/deployment-checks/{id}/override (manual override with justification)
  GET /v1/deployment-checks/stats (approval/block rate)

Check logic: queries G1 decision_traces + G3 open failures per affected control.
Verdict: approved (0 blocking) or blocked (with fix recommendations).
454 tests pass, 0 regressions.

Block G complete: G1-G4 all implemented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-06 20:24:45 +02:00
parent c398e74d5e
commit d5bcd0bd5b
3 changed files with 298 additions and 0 deletions
+2
View File
@@ -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)
@@ -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()
@@ -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);