diff --git a/control-pipeline/api/__init__.py b/control-pipeline/api/__init__.py index a4c2ec6..4e282f7 100644 --- a/control-pipeline/api/__init__.py +++ b/control-pipeline/api/__init__.py @@ -7,6 +7,7 @@ from api.dependency_routes import router as dependency_router from api.master_control_routes import router as master_control_router 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 router = APIRouter() router.include_router(generator_router) @@ -16,3 +17,4 @@ router.include_router(dependency_router) router.include_router(master_control_router) router.include_router(decision_trace_router) router.include_router(full_trace_router) +router.include_router(compliance_commit_router) diff --git a/control-pipeline/api/compliance_commit_routes.py b/control-pipeline/api/compliance_commit_routes.py new file mode 100644 index 0000000..6a610b2 --- /dev/null +++ b/control-pipeline/api/compliance_commit_routes.py @@ -0,0 +1,255 @@ +"""Compliance Commit Ledger API — G2. + +Tracks code commits and their compliance impact. SDK reports each commit +with affected controls, building an audit trail for code↔compliance mapping. +""" + +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/compliance-commits", tags=["compliance-commits"]) + + +class CreateCommitRequest(BaseModel): + tenant_id: str + project_id: Optional[str] = None + commit_hash: str + commit_message: Optional[str] = None + commit_author: Optional[str] = None + commit_date: Optional[str] = None + branch: Optional[str] = None + repo_url: Optional[str] = None + affected_control_ids: list[str] = [] + affected_files: list[str] = [] + risk_level: str = "low" + analysis_summary: Optional[str] = None + analysis_metadata: dict = {} + + +@router.post("") +async def register_commit(req: CreateCommitRequest): + """Register a code commit with its compliance impact.""" + db = SessionLocal() + try: + cid = str(uuid.uuid4()) + db.execute(text(""" + INSERT INTO compliance_commits + (id, tenant_id, project_id, commit_hash, commit_message, + commit_author, commit_date, branch, repo_url, + affected_control_ids, affected_files, + risk_level, analysis_summary, analysis_metadata) + VALUES + (CAST(:id AS uuid), CAST(:tenant_id AS uuid), :project_id, + :commit_hash, :commit_message, :commit_author, + :commit_date, :branch, :repo_url, + CAST(:control_ids AS jsonb), CAST(:files AS jsonb), + :risk_level, :analysis_summary, CAST(:metadata AS jsonb)) + """), { + "id": cid, + "tenant_id": req.tenant_id, + "project_id": req.project_id, + "commit_hash": req.commit_hash, + "commit_message": req.commit_message, + "commit_author": req.commit_author, + "commit_date": req.commit_date, + "branch": req.branch, + "repo_url": req.repo_url, + "control_ids": json.dumps(req.affected_control_ids), + "files": json.dumps(req.affected_files), + "risk_level": req.risk_level, + "analysis_summary": req.analysis_summary, + "metadata": json.dumps(req.analysis_metadata), + }) + db.commit() + return { + "id": cid, + "status": "registered", + "affected_controls": len(req.affected_control_ids), + "risk_level": req.risk_level, + } + finally: + db.close() + + +@router.get("") +async def list_commits( + tenant_id: Optional[str] = None, + control_id: Optional[str] = None, + risk_level: Optional[str] = None, + branch: Optional[str] = None, + since: Optional[str] = None, + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + """List compliance commits with filters.""" + db = SessionLocal() + try: + clauses = [] + params: dict = {"limit": limit, "offset": offset} + + if tenant_id: + clauses.append("tenant_id = CAST(:tenant_id AS uuid)") + params["tenant_id"] = tenant_id + if control_id: + clauses.append("affected_control_ids @> CAST(:cid_json AS jsonb)") + params["cid_json"] = json.dumps([control_id]) + if risk_level: + clauses.append("risk_level = :risk") + params["risk"] = risk_level + if branch: + clauses.append("branch = :branch") + params["branch"] = branch + if since: + clauses.append("commit_date >= CAST(:since AS timestamptz)") + params["since"] = since + + where = "WHERE " + " AND ".join(clauses) if clauses else "" + + rows = db.execute(text(f""" + SELECT id, commit_hash, commit_message, commit_author, commit_date, + branch, affected_control_ids, affected_files, risk_level + FROM compliance_commits + {where} + ORDER BY commit_date DESC NULLS LAST + LIMIT :limit OFFSET :offset + """), params).fetchall() + + total = db.execute(text(f""" + SELECT count(*) FROM compliance_commits {where} + """), params).scalar() + + return { + "total": total, + "commits": [ + { + "id": str(r[0]), + "commit_hash": r[1], + "message": r[2], + "author": r[3], + "date": str(r[4]) if r[4] else None, + "branch": r[5], + "affected_control_ids": r[6], + "affected_files": r[7], + "risk_level": r[8], + } + for r in rows + ], + } + finally: + db.close() + + +@router.get("/stats") +async def commit_stats(tenant_id: Optional[str] = None): + """Dashboard stats for compliance commits.""" + db = SessionLocal() + try: + tf = "" + params: dict = {} + if tenant_id: + tf = "WHERE tenant_id = CAST(:tid AS uuid)" + params["tid"] = tenant_id + + risk = db.execute(text(f""" + SELECT risk_level, count(*) FROM compliance_commits {tf} + GROUP BY risk_level + """), params).fetchall() + + recent = db.execute(text(f""" + SELECT count(*) FROM compliance_commits + {tf + ' AND' if tf else 'WHERE'} commit_date > NOW() - interval '7 days' + """), params).scalar() + + total = sum(r[1] for r in risk) + + return { + "total_commits": total, + "last_7_days": recent, + "by_risk_level": {r[0]: r[1] for r in risk}, + } + finally: + db.close() + + +@router.get("/by-control/{control_id}") +async def commits_by_control( + control_id: str, + limit: int = Query(50, ge=1, le=200), +): + """Get all commits that affect a specific control.""" + db = SessionLocal() + try: + rows = db.execute(text(""" + SELECT id, commit_hash, commit_message, commit_author, commit_date, + branch, repo_url, affected_files, risk_level + FROM compliance_commits + WHERE affected_control_ids @> CAST(:cid_json AS jsonb) + ORDER BY commit_date DESC NULLS LAST + LIMIT :limit + """), { + "cid_json": json.dumps([control_id]), + "limit": limit, + }).fetchall() + + return { + "control_id": control_id, + "total_commits": len(rows), + "commits": [ + { + "id": str(r[0]), + "commit_hash": r[1], + "message": r[2], + "author": r[3], + "date": str(r[4]) if r[4] else None, + "branch": r[5], + "repo_url": r[6], + "affected_files": r[7], + "risk_level": r[8], + } + for r in rows + ], + } + finally: + db.close() + + +@router.get("/{commit_id}") +async def get_commit(commit_id: str): + """Get details of a single compliance commit.""" + db = SessionLocal() + try: + row = db.execute(text(""" + SELECT * FROM compliance_commits WHERE id = CAST(:id AS uuid) + """), {"id": commit_id}).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Commit not found") + + return { + "id": str(row.id), + "tenant_id": str(row.tenant_id), + "project_id": str(row.project_id) if row.project_id else None, + "commit_hash": row.commit_hash, + "commit_message": row.commit_message, + "commit_author": row.commit_author, + "commit_date": str(row.commit_date) if row.commit_date else None, + "branch": row.branch, + "repo_url": row.repo_url, + "affected_control_ids": row.affected_control_ids, + "affected_files": row.affected_files, + "risk_level": row.risk_level, + "analysis_summary": row.analysis_summary, + "analysis_metadata": row.analysis_metadata, + } + finally: + db.close() diff --git a/control-pipeline/migrations/007_compliance_commits.sql b/control-pipeline/migrations/007_compliance_commits.sql new file mode 100644 index 0000000..1c6a4b0 --- /dev/null +++ b/control-pipeline/migrations/007_compliance_commits.sql @@ -0,0 +1,38 @@ +-- Migration 007: Compliance Commit Ledger (G2) +-- Schema: compliance +-- Run: ssh macmini "docker exec -i bp-core-postgres psql -U breakpilot -d breakpilot_db" < control-pipeline/migrations/007_compliance_commits.sql + +SET search_path TO compliance, public; + +CREATE TABLE IF NOT EXISTS compliance_commits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + project_id UUID, + + -- Git Info + commit_hash VARCHAR(64) NOT NULL, + commit_message TEXT, + commit_author VARCHAR(200), + commit_date TIMESTAMPTZ, + branch VARCHAR(200), + repo_url TEXT, + + -- Affected Controls + affected_control_ids JSONB NOT NULL DEFAULT '[]', + affected_files JSONB DEFAULT '[]', + + -- Analysis + risk_level VARCHAR(20) DEFAULT 'low' + CHECK (risk_level IN ('low', 'medium', 'high', 'critical')), + analysis_summary TEXT, + analysis_metadata JSONB DEFAULT '{}', + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cc_tenant ON compliance_commits(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cc_hash ON compliance_commits(commit_hash); +CREATE INDEX IF NOT EXISTS idx_cc_date ON compliance_commits(commit_date); +CREATE INDEX IF NOT EXISTS idx_cc_risk ON compliance_commits(risk_level); +-- GIN index for JSONB array containment queries (@>) +CREATE INDEX IF NOT EXISTS idx_cc_control_ids ON compliance_commits USING GIN (affected_control_ids);