feat(compliance): GET /sdk/v1/compliance/obligation-status (file-backed graph)
Vertical slice over the Compliance Execution Graph: obligation_id -> accepted
controls -> required evidence -> status. NEVER auto-asserts fulfillment - with
no evidence collection wired (MVP), a mapped obligation is "not_assessed" and
every required evidence is "missing". Fail-closed: no id -> 400; unknown id ->
unknown_obligation; mapped-but-no-control -> unmapped; graph not loaded -> 503.
- ComplianceGraphHandlers (separate from the DB-backed ObligationsHandlers):
loads Registry join keys + accepted control mappings + evidence once at start.
- LoadComplianceGraph: candidate-path resolution across dev/container/test.
- Data plumbing: Dockerfile now COPYs data/{control_mappings,evidence_requirements,
obligations}; data/obligations/obligation_join_keys.json is a SYNCED COPY of the
repo-root Registry contract (re-sync on Registry growth).
- Table-driven handler test (mapped/unmapped/unknown/400 + no-fulfillment-claim).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
)
|
||||
|
||||
// ComplianceGraphHandlers serves the read-only Compliance Execution Graph
|
||||
// (Regulation -> Obligation -> Control -> Evidence) over the file-backed bridge artifacts.
|
||||
// It is intentionally SEPARATE from the DB-backed ObligationsHandlers: this is the curated
|
||||
// cross-session graph (Registry join keys + accepted control mappings + evidence requirements),
|
||||
// loaded once at startup. Fail-closed: if the graph could not load, every request answers 503.
|
||||
type ComplianceGraphHandlers struct {
|
||||
joins *ucca.ObligationJoinKeys
|
||||
mappings *ucca.ControlMappingSet
|
||||
evidence *ucca.EvidenceRequirementSet
|
||||
loadErr error
|
||||
}
|
||||
|
||||
// NewComplianceGraphHandlers loads the graph once. Construction never fails; a load error is
|
||||
// retained and surfaced as 503 per request (matches the codebase's load-warn-continue startup).
|
||||
func NewComplianceGraphHandlers() *ComplianceGraphHandlers {
|
||||
joins, mappings, evidence, err := ucca.LoadComplianceGraph()
|
||||
return &ComplianceGraphHandlers{joins: joins, mappings: mappings, evidence: evidence, loadErr: err}
|
||||
}
|
||||
|
||||
// LoadError exposes a startup load failure so the wiring can log a warning.
|
||||
func (h *ComplianceGraphHandlers) LoadError() error { return h.loadErr }
|
||||
|
||||
// RegisterRoutes mounts the compliance-graph routes under /compliance.
|
||||
func (h *ComplianceGraphHandlers) RegisterRoutes(r *gin.RouterGroup) {
|
||||
g := r.Group("/compliance")
|
||||
g.GET("/obligation-status", h.ObligationStatus)
|
||||
}
|
||||
|
||||
type cgControlDTO struct {
|
||||
Framework string `json:"framework"`
|
||||
Control string `json:"control"`
|
||||
MappingType string `json:"mapping_type"`
|
||||
EvidenceRequired []string `json:"evidence_required"`
|
||||
EvidenceStatus string `json:"evidence_status"` // missing | partial | present | none_required
|
||||
}
|
||||
|
||||
type cgStatusResponse struct {
|
||||
ObligationID string `json:"obligation_id"`
|
||||
OverallStatus string `json:"overall_status"` // unknown_obligation | unmapped | not_assessed | open | met
|
||||
LegalBasis []string `json:"legal_basis,omitempty"`
|
||||
CitationSpans string `json:"citation_spans"` // "pending" until the Legal-KG attaches spans
|
||||
Controls []cgControlDTO `json:"controls"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// ObligationStatus answers GET /sdk/v1/compliance/obligation-status?obligation_id=...
|
||||
//
|
||||
// It NEVER asserts fulfillment automatically. With no evidence collection wired (MVP), a mapped
|
||||
// obligation is "not_assessed" and every required evidence is "missing" — the honest picture is
|
||||
// "required vs present evidence", not "a document exists". Fail-closed otherwise:
|
||||
// - no obligation_id -> 400
|
||||
// - graph not loaded -> 503
|
||||
// - id not in the Registry -> 200 overall_status=unknown_obligation
|
||||
// - mapped but no control yet -> 200 overall_status=unmapped
|
||||
func (h *ComplianceGraphHandlers) ObligationStatus(c *gin.Context) {
|
||||
if h.loadErr != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compliance graph unavailable", "detail": h.loadErr.Error()})
|
||||
return
|
||||
}
|
||||
obID := strings.TrimSpace(c.Query("obligation_id"))
|
||||
if obID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "obligation_id query parameter required"})
|
||||
return
|
||||
}
|
||||
resp := cgStatusResponse{ObligationID: obID, CitationSpans: "pending", Controls: []cgControlDTO{}}
|
||||
|
||||
if h.joins.FindObligation(obID) == nil {
|
||||
resp.OverallStatus = "unknown_obligation"
|
||||
resp.Note = "obligation_id not in the Registry join-key contract"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
// MVP: hasEvidence=nil -> no collection wired -> all required evidence counts as missing.
|
||||
st := ucca.AssessObligationStatus(h.joins, h.mappings, h.evidence, obID, nil)
|
||||
resp.LegalBasis = st.LegalBasis
|
||||
|
||||
if len(st.Controls) == 0 {
|
||||
resp.OverallStatus = "unmapped"
|
||||
resp.Note = "no accepted control maps to this obligation yet"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
for _, cs := range st.Controls {
|
||||
types := make([]string, 0, len(cs.RequiredEvidence))
|
||||
for _, e := range cs.RequiredEvidence {
|
||||
types = append(types, e.EvidenceType)
|
||||
}
|
||||
resp.Controls = append(resp.Controls, cgControlDTO{
|
||||
Framework: cs.Framework,
|
||||
Control: cs.Control,
|
||||
MappingType: cs.MappingType,
|
||||
EvidenceRequired: types,
|
||||
EvidenceStatus: cgEvidenceStatus(len(cs.RequiredEvidence), len(cs.MissingEvidence)),
|
||||
})
|
||||
}
|
||||
// No fulfillment claim without real evidence collection.
|
||||
resp.OverallStatus = "not_assessed"
|
||||
resp.Note = "evidence collection not wired (MVP) — fulfillment not asserted"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func cgEvidenceStatus(required, missing int) string {
|
||||
switch {
|
||||
case required == 0:
|
||||
return "none_required"
|
||||
case missing == 0:
|
||||
return "present"
|
||||
case missing == required:
|
||||
return "missing"
|
||||
default:
|
||||
return "partial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newComplianceGraphTestRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewComplianceGraphHandlers()
|
||||
if err := h.LoadError(); err != nil {
|
||||
t.Fatalf("compliance graph failed to load (candidate paths): %v", err)
|
||||
}
|
||||
r := gin.New()
|
||||
h.RegisterRoutes(r.Group("/sdk/v1"))
|
||||
return r
|
||||
}
|
||||
|
||||
func getObligationStatus(t *testing.T, r *gin.Engine, query string) (int, cgStatusResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/sdk/v1/compliance/obligation-status"+query, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
var resp cgStatusResponse
|
||||
if w.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
func TestObligationStatus(t *testing.T) {
|
||||
r := newComplianceGraphTestRouter(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantHTTP int
|
||||
wantOverall string
|
||||
wantControls bool // expect >=1 control
|
||||
}{
|
||||
{"missing param -> 400", "", http.StatusBadRequest, "", false},
|
||||
{"unknown id -> unknown_obligation", "?obligation_id=does_not_exist", http.StatusOK, "unknown_obligation", false},
|
||||
{"mapped (OWASP V6) -> not_assessed", "?obligation_id=user_authentication_required", http.StatusOK, "not_assessed", true},
|
||||
{"in registry, no control -> unmapped", "?obligation_id=sbom_creation", http.StatusOK, "unmapped", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, resp := getObligationStatus(t, r, tt.query)
|
||||
if code != tt.wantHTTP {
|
||||
t.Fatalf("http %d, want %d", code, tt.wantHTTP)
|
||||
}
|
||||
if tt.wantHTTP != http.StatusOK {
|
||||
return
|
||||
}
|
||||
if resp.OverallStatus != tt.wantOverall {
|
||||
t.Errorf("overall_status=%q, want %q", resp.OverallStatus, tt.wantOverall)
|
||||
}
|
||||
if tt.wantControls && len(resp.Controls) == 0 {
|
||||
t.Error("expected >=1 control")
|
||||
}
|
||||
if !tt.wantControls && len(resp.Controls) != 0 {
|
||||
t.Errorf("expected 0 controls, got %d", len(resp.Controls))
|
||||
}
|
||||
if resp.CitationSpans != "pending" {
|
||||
t.Errorf("citation_spans=%q, want pending", resp.CitationSpans)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The MVP must NEVER auto-assert fulfillment: with no evidence collection wired, every required
|
||||
// evidence is "missing" and the overall status stays "not_assessed".
|
||||
func TestObligationStatus_NoFulfillmentClaim(t *testing.T) {
|
||||
r := newComplianceGraphTestRouter(t)
|
||||
code, resp := getObligationStatus(t, r, "?obligation_id=user_authentication_required")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("http %d", code)
|
||||
}
|
||||
if resp.OverallStatus == "met" || resp.OverallStatus == "erfuellt" {
|
||||
t.Fatalf("MVP must not assert fulfillment, got overall_status=%q", resp.OverallStatus)
|
||||
}
|
||||
for _, ctl := range resp.Controls {
|
||||
if len(ctl.EvidenceRequired) > 0 && ctl.EvidenceStatus != "missing" {
|
||||
t.Errorf("control %s/%s evidence_status=%q, want missing (no collection wired)", ctl.Framework, ctl.Control, ctl.EvidenceStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user