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:
@@ -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);
|
||||
Reference in New Issue
Block a user