From 2cfcb90989e0f87274ca5225afc664ddbce39395 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 20 Mar 2026 11:11:01 -0400 Subject: [PATCH 1/4] feat(pip): RFC-005 PEP middleware, proto fields, guard PDP integration Step 4: NewPolicyMiddleware in pkg/gateway/ - PEPConfig with PDPClient, EnforcementMode, ObligationRegistry, DecisionCache - Full PEP flow: badge verify, break-glass, cache, PDP, enforce, obligations - 95.2% test coverage (33 test cases) Step 5: Proto changes in proto/capiscio/v1/mcp.proto - EvaluateToolAccessRequest: enforcement_mode, capability_class, envelope_id, delegation_depth, constraints_json, parent_constraints_json - EvaluateToolAccessResponse: policy_decision_id, policy_decision, enforcement_mode, repeated MCPObligation obligations - New MCPObligation message Step 6: Guard PDP integration in pkg/mcp/ - GuardOption pattern: WithPDPClient, WithEnforcementMode, WithObligationRegistry - evaluateWithPDP: builds PIP request, queries PDP, handles EM matrix - evaluateInlinePolicy: refactored legacy trust level + tool glob checks - PDP replaces inline policy (authentication always runs first) - 15 PDP-specific test cases, evaluateWithPDP 100% coverage Implements Steps 4-6 of the RFC-005 PIP implementation guide v1.2. --- pkg/gateway/middleware.go | 288 +++++++ pkg/gateway/policy_middleware_test.go | 1021 +++++++++++++++++++++++++ pkg/mcp/guard.go | 184 ++++- pkg/mcp/guard_pdp_test.go | 533 +++++++++++++ pkg/mcp/types.go | 6 + pkg/pip/breakglass.go | 24 + pkg/rpc/gen/capiscio/v1/mcp.pb.go | 293 +++++-- proto/capiscio/v1/mcp.proto | 24 + 8 files changed, 2285 insertions(+), 88 deletions(-) create mode 100644 pkg/gateway/policy_middleware_test.go create mode 100644 pkg/mcp/guard_pdp_test.go diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 4c08bf6..d17ec1e 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -2,14 +2,21 @@ package gateway import ( + "crypto" "log" + "log/slog" "net/http" "strings" + "time" + + "github.com/google/uuid" "github.com/capiscio/capiscio-core/v2/pkg/badge" + "github.com/capiscio/capiscio-core/v2/pkg/pip" ) // NewAuthMiddleware creates a middleware that enforces Badge validity. +// Deprecated: Use NewPolicyMiddleware for RFC-005 PDP integration. func NewAuthMiddleware(verifier *badge.Verifier, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Extract Badge @@ -35,6 +42,239 @@ func NewAuthMiddleware(verifier *badge.Verifier, next http.Handler) http.Handler }) } +// PEPConfig configures the Policy Enforcement Point middleware (RFC-005). +type PEPConfig struct { + PDPClient pip.PDPClient // nil = badge-only mode (skip PDP) + EnforcementMode pip.EnforcementMode // default EMObserve + ObligationReg *pip.ObligationRegistry // nil = no obligation handling + DecisionCache pip.DecisionCache // nil = no caching + BreakGlassKey crypto.PublicKey // nil = break-glass disabled + PEPID string // PEP instance identifier + Workspace string // workspace/tenant identifier + Logger *slog.Logger // nil = slog.Default() +} + +// PolicyEvent captures telemetry for a policy enforcement decision. +type PolicyEvent struct { + Decision string + DecisionID string + Override bool + OverrideJTI string + CacheHit bool + PDPLatencyMs int64 + Obligations []string + ErrorCode string +} + +// PolicyEventCallback is invoked after each policy enforcement with the event data. +// Implementations should be non-blocking. +type PolicyEventCallback func(event PolicyEvent, req *pip.DecisionRequest) + +// NewPolicyMiddleware creates a full PEP middleware (RFC-005). +// When PEPConfig.PDPClient is nil, operates in badge-only mode (identical to NewAuthMiddleware). +func NewPolicyMiddleware(verifier *badge.Verifier, config PEPConfig, next http.Handler, callbacks ...PolicyEventCallback) http.Handler { + logger := config.Logger + if logger == nil { + logger = slog.Default() + } + + var bgValidator *pip.BreakGlassValidator + if config.BreakGlassKey != nil { + bgValidator = pip.NewBreakGlassValidator(config.BreakGlassKey) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // --- 1. Extract and verify badge (authentication) --- + token := ExtractBadge(r) + if token == "" { + http.Error(w, "Missing Trust Badge", http.StatusUnauthorized) + return + } + + claims, err := verifier.Verify(r.Context(), token) + if err != nil { + logger.WarnContext(r.Context(), "badge verification failed", slog.String("error", err.Error())) + http.Error(w, "Invalid Trust Badge", http.StatusUnauthorized) + return + } + + // Forward verified identity to upstream + r.Header.Set("X-Capiscio-Subject", claims.Subject) + r.Header.Set("X-Capiscio-Issuer", claims.Issuer) + + // If no PDP configured, operate in badge-only mode + if config.PDPClient == nil { + next.ServeHTTP(w, r) + return + } + + // --- 2. Resolve txn_id (RFC-004 header or generate UUID v7) --- + txnID := r.Header.Get(pip.TxnIDHeader) + if txnID == "" { + txnID = uuid.Must(uuid.NewV7()).String() + } + r.Header.Set(pip.TxnIDHeader, txnID) + + // --- 3. Build PIP request --- + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + pipReq := &pip.DecisionRequest{ + PIPVersion: pip.PIPVersion, + Subject: pip.SubjectAttributes{ + DID: claims.Subject, + BadgeJTI: claims.JTI, + IAL: claims.IAL, + TrustLevel: claims.TrustLevel(), + }, + Action: pip.ActionAttributes{ + Operation: r.Method + " " + r.URL.Path, + }, + Resource: pip.ResourceAttributes{ + Identifier: r.URL.Path, + }, + Context: pip.ContextAttributes{ + TxnID: txnID, + EnforcementMode: config.EnforcementMode.String(), + }, + Environment: pip.EnvironmentAttrs{ + PEPID: strPtr(config.PEPID), + Workspace: strPtr(config.Workspace), + Time: &nowStr, + }, + } + + event := PolicyEvent{} + + // --- 4. Check break-glass override --- + if bgValidator != nil { + if bgToken := extractBreakGlass(r, bgValidator); bgToken != nil { + logger.WarnContext(r.Context(), "break-glass override active", + slog.String(pip.TelemetryOverrideJTI, bgToken.JTI), + slog.String("operator", bgToken.SUB), + slog.String("reason", bgToken.Reason)) + + event.Decision = pip.DecisionAllow + event.DecisionID = "breakglass:" + bgToken.JTI + event.Override = true + event.OverrideJTI = bgToken.JTI + emitPolicyEvent(callbacks, event, pipReq) + next.ServeHTTP(w, r) + return + } + } + + // --- 5. Check cache --- + cacheKey := pip.CacheKeyComponents(claims.Subject, claims.JTI, pipReq.Action.Operation, pipReq.Resource.Identifier) + if config.DecisionCache != nil { + if cached, ok := config.DecisionCache.Get(cacheKey); ok { + event.Decision = cached.Decision + event.DecisionID = cached.DecisionID + event.CacheHit = true + event.Obligations = obligationTypes(cached.Obligations) + + if cached.Decision == pip.DecisionDeny { + emitPolicyEvent(callbacks, event, pipReq) + http.Error(w, "Access denied by policy", http.StatusForbidden) + return + } + + // Handle obligations from cached response + if config.ObligationReg != nil && len(cached.Obligations) > 0 { + oblResult := config.ObligationReg.Enforce(r.Context(), config.EnforcementMode, cached.Obligations) + if !oblResult.Proceed { + event.Decision = pip.DecisionDeny + emitPolicyEvent(callbacks, event, pipReq) + http.Error(w, "Access denied: obligation enforcement failed", http.StatusForbidden) + return + } + } + + emitPolicyEvent(callbacks, event, pipReq) + next.ServeHTTP(w, r) + return + } + } + + // --- 6. Query PDP --- + start := time.Now() + resp, pdpErr := config.PDPClient.Evaluate(r.Context(), pipReq) + event.PDPLatencyMs = time.Since(start).Milliseconds() + + if pdpErr != nil { + // PDP unavailable — handle per enforcement mode (RFC-005 §7.4) + event.ErrorCode = pip.ErrorCodePDPUnavailable + logger.ErrorContext(r.Context(), "PDP unavailable", + slog.String(pip.TelemetryErrorCode, pip.ErrorCodePDPUnavailable), + slog.String("error", pdpErr.Error()), + slog.String("enforcement_mode", config.EnforcementMode.String())) + + if config.EnforcementMode == pip.EMObserve { + event.Decision = pip.DecisionObserve + event.DecisionID = "pdp-unavailable" + emitPolicyEvent(callbacks, event, pipReq) + next.ServeHTTP(w, r) + return + } + // EM-GUARD, EM-DELEGATE, EM-STRICT: fail-closed + event.Decision = pip.DecisionDeny + event.DecisionID = "pdp-unavailable" + emitPolicyEvent(callbacks, event, pipReq) + http.Error(w, "Access denied: policy service unavailable", http.StatusForbidden) + return + } + + event.Decision = resp.Decision + event.DecisionID = resp.DecisionID + event.Obligations = obligationTypes(resp.Obligations) + + // --- 7. Cache the response --- + if config.DecisionCache != nil { + maxTTL := time.Until(time.Unix(claims.Expiry, 0)) + if maxTTL > 0 { + config.DecisionCache.Put(cacheKey, resp, maxTTL) + } + } + + // --- 8. Enforce decision --- + if resp.Decision == pip.DecisionDeny { + switch config.EnforcementMode { + case pip.EMObserve: + // Log only, allow through + logger.InfoContext(r.Context(), "PDP DENY in EM-OBSERVE (allowing)", + slog.String(pip.TelemetryDecisionID, resp.DecisionID)) + event.Decision = pip.DecisionObserve + emitPolicyEvent(callbacks, event, pipReq) + next.ServeHTTP(w, r) + return + default: + // EM-GUARD, EM-DELEGATE, EM-STRICT: block + reason := "Access denied by policy" + if resp.Reason != "" { + reason = resp.Reason + } + emitPolicyEvent(callbacks, event, pipReq) + http.Error(w, reason, http.StatusForbidden) + return + } + } + + // --- 9. Handle obligations --- + if config.ObligationReg != nil && len(resp.Obligations) > 0 { + oblResult := config.ObligationReg.Enforce(r.Context(), config.EnforcementMode, resp.Obligations) + if !oblResult.Proceed { + event.Decision = pip.DecisionDeny + emitPolicyEvent(callbacks, event, pipReq) + http.Error(w, "Access denied: obligation enforcement failed", http.StatusForbidden) + return + } + } + + // --- 10. Emit telemetry and forward --- + emitPolicyEvent(callbacks, event, pipReq) + next.ServeHTTP(w, r) + }) +} + // ExtractBadge retrieves the badge from headers. func ExtractBadge(r *http.Request) string { // 1. X-Capiscio-Badge @@ -50,3 +290,51 @@ func ExtractBadge(r *http.Request) string { return "" } + +// extractBreakGlass checks for a break-glass token in the request, validates it, +// and returns the token if valid and scope-matched. Returns nil otherwise. +func extractBreakGlass(r *http.Request, v *pip.BreakGlassValidator) *pip.BreakGlassToken { + raw := r.Header.Get("X-Capiscio-Breakglass") + if raw == "" { + return nil + } + + token, err := pip.ParseBreakGlassJWS(raw, v.PublicKey()) + if err != nil { + return nil + } + + if err := v.ValidateToken(token); err != nil { + return nil + } + + if !v.MatchesScope(token, r.Method, r.URL.Path) { + return nil + } + + return token +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func obligationTypes(obs []pip.Obligation) []string { + if len(obs) == 0 { + return nil + } + types := make([]string, len(obs)) + for i, o := range obs { + types[i] = o.Type + } + return types +} + +func emitPolicyEvent(callbacks []PolicyEventCallback, event PolicyEvent, req *pip.DecisionRequest) { + for _, cb := range callbacks { + cb(event, req) + } +} diff --git a/pkg/gateway/policy_middleware_test.go b/pkg/gateway/policy_middleware_test.go new file mode 100644 index 0000000..4b17a67 --- /dev/null +++ b/pkg/gateway/policy_middleware_test.go @@ -0,0 +1,1021 @@ +package gateway_test + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/capiscio/capiscio-core/v2/pkg/badge" + "github.com/capiscio/capiscio-core/v2/pkg/gateway" + "github.com/capiscio/capiscio-core/v2/pkg/pip" + "github.com/capiscio/capiscio-core/v2/pkg/registry" +) + +// --- Test helpers --- + +type mockRegistry struct { + key crypto.PublicKey +} + +func (m *mockRegistry) GetPublicKey(_ context.Context, _ string) (crypto.PublicKey, error) { + return m.key, nil +} + +func (m *mockRegistry) IsRevoked(_ context.Context, _ string) (bool, error) { + return false, nil +} + +func (m *mockRegistry) GetBadgeStatus(_ context.Context, _ string, jti string) (*registry.BadgeStatus, error) { + return ®istry.BadgeStatus{JTI: jti, Revoked: false}, nil +} + +func (m *mockRegistry) GetAgentStatus(_ context.Context, _ string, agentID string) (*registry.AgentStatus, error) { + return ®istry.AgentStatus{ID: agentID, Status: registry.AgentStatusActive}, nil +} + +func (m *mockRegistry) SyncRevocations(_ context.Context, _ string, _ time.Time) ([]registry.Revocation, error) { + return nil, nil +} + +type mockPDP struct { + resp *pip.DecisionResponse + err error + mu sync.Mutex + reqs []*pip.DecisionRequest +} + +func (m *mockPDP) Evaluate(_ context.Context, req *pip.DecisionRequest) (*pip.DecisionResponse, error) { + m.mu.Lock() + m.reqs = append(m.reqs, req) + m.mu.Unlock() + return m.resp, m.err +} + +func (m *mockPDP) callCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.reqs) +} + +func (m *mockPDP) lastRequest() *pip.DecisionRequest { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.reqs) == 0 { + return nil + } + return m.reqs[len(m.reqs)-1] +} + +type mockObligationHandler struct { + supported string + err error +} + +func (h *mockObligationHandler) Handle(_ context.Context, _ pip.Obligation) error { + return h.err +} + +func (h *mockObligationHandler) Supports(t string) bool { + return t == h.supported +} + +// testSetup creates a common test environment. +type testSetup struct { + pub ed25519.PublicKey + priv ed25519.PrivateKey + verifier *badge.Verifier + token string + claims *badge.Claims +} + +func newTestSetup(t *testing.T) *testSetup { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + reg := &mockRegistry{key: pub} + verifier := badge.NewVerifier(reg) + + claims := &badge.Claims{ + JTI: "test-jti-policy", + Issuer: "did:web:test.capisc.io", + Subject: "did:web:test.capisc.io:agents:test-agent", + IssuedAt: time.Now().Unix(), + Expiry: time.Now().Add(1 * time.Hour).Unix(), + IAL: "IAL-1", + VC: badge.VerifiableCredential{ + Type: []string{"VerifiableCredential", "AgentIdentity"}, + CredentialSubject: badge.CredentialSubject{ + Domain: "test.example.com", + Level: "2", + }, + }, + } + token, err := badge.SignBadge(claims, priv) + require.NoError(t, err) + + return &testSetup{ + pub: pub, + priv: priv, + verifier: verifier, + token: token, + claims: claims, + } +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) +} + +func signBreakGlassToken(t *testing.T, priv ed25519.PrivateKey, token *pip.BreakGlassToken) string { + t.Helper() + payload, err := json.Marshal(token) + require.NoError(t, err) + + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: priv}, nil) + require.NoError(t, err) + + jws, err := signer.Sign(payload) + require.NoError(t, err) + + compact, err := jws.CompactSerialize() + require.NoError(t, err) + + return compact +} + +// --- Tests --- + +func TestPolicyMiddleware_BadgeOnlyMode(t *testing.T) { + ts := newTestSetup(t) + + config := gateway.PEPConfig{ + // PDPClient nil = badge-only mode + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + t.Run("valid badge passes through", func(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "OK", rr.Body.String()) + }) + + t.Run("missing badge returns 401", func(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/agents", nil) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("invalid badge returns 401", func(t *testing.T) { + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", "invalid.token") + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) +} + +func TestPolicyMiddleware_PDPAllow(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "test-decision-001", + }, + } + + var capturedEvent gateway.PolicyEvent + callback := func(event gateway.PolicyEvent, req *pip.DecisionRequest) { + capturedEvent = event + } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMDelegate, + PEPID: "test-pep", + Workspace: "test-workspace", + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req := httptest.NewRequest("GET", "/v1/agents/abc", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, 1, pdp.callCount()) + + // Verify PIP request + pipReq := pdp.lastRequest() + assert.Equal(t, pip.PIPVersion, pipReq.PIPVersion) + assert.Equal(t, ts.claims.Subject, pipReq.Subject.DID) + assert.Equal(t, ts.claims.JTI, pipReq.Subject.BadgeJTI) + assert.Equal(t, "IAL-1", pipReq.Subject.IAL) + assert.Equal(t, "2", pipReq.Subject.TrustLevel) + assert.Equal(t, "GET /v1/agents/abc", pipReq.Action.Operation) + assert.Equal(t, "/v1/agents/abc", pipReq.Resource.Identifier) + assert.Equal(t, pip.EMDelegate.String(), pipReq.Context.EnforcementMode) + assert.Nil(t, pipReq.Action.CapabilityClass) + assert.Nil(t, pipReq.Context.EnvelopeID) + assert.Nil(t, pipReq.Context.Constraints) + assert.NotEmpty(t, pipReq.Context.TxnID) + + // Verify event callback + assert.Equal(t, pip.DecisionAllow, capturedEvent.Decision) + assert.Equal(t, "test-decision-001", capturedEvent.DecisionID) + assert.False(t, capturedEvent.Override) + assert.False(t, capturedEvent.CacheHit) +} + +func TestPolicyMiddleware_PDPDeny(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionDeny, + DecisionID: "deny-001", + Reason: "insufficient permissions", + }, + } + + tests := []struct { + name string + mode pip.EnforcementMode + expectedStatus int + expectedDecision string + }{ + { + name: "EM-OBSERVE allows through on DENY", + mode: pip.EMObserve, + expectedStatus: http.StatusOK, + expectedDecision: pip.DecisionObserve, + }, + { + name: "EM-GUARD blocks on DENY", + mode: pip.EMGuard, + expectedStatus: http.StatusForbidden, + expectedDecision: pip.DecisionDeny, + }, + { + name: "EM-DELEGATE blocks on DENY", + mode: pip.EMDelegate, + expectedStatus: http.StatusForbidden, + expectedDecision: pip.DecisionDeny, + }, + { + name: "EM-STRICT blocks on DENY", + mode: pip.EMStrict, + expectedStatus: http.StatusForbidden, + expectedDecision: pip.DecisionDeny, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var capturedEvent gateway.PolicyEvent + callback := func(event gateway.PolicyEvent, req *pip.DecisionRequest) { + capturedEvent = event + } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: tc.mode, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + assert.Equal(t, tc.expectedDecision, capturedEvent.Decision) + }) + } +} + +func TestPolicyMiddleware_PDPUnavailable(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + err: fmt.Errorf("connection refused"), + } + + tests := []struct { + name string + mode pip.EnforcementMode + expectedStatus int + expectedDecision string + expectedError string + }{ + { + name: "EM-OBSERVE allows on PDP unavailable", + mode: pip.EMObserve, + expectedStatus: http.StatusOK, + expectedDecision: pip.DecisionObserve, + expectedError: pip.ErrorCodePDPUnavailable, + }, + { + name: "EM-GUARD denies on PDP unavailable (fail-closed)", + mode: pip.EMGuard, + expectedStatus: http.StatusForbidden, + expectedDecision: pip.DecisionDeny, + expectedError: pip.ErrorCodePDPUnavailable, + }, + { + name: "EM-DELEGATE denies on PDP unavailable (fail-closed)", + mode: pip.EMDelegate, + expectedStatus: http.StatusForbidden, + expectedDecision: pip.DecisionDeny, + expectedError: pip.ErrorCodePDPUnavailable, + }, + { + name: "EM-STRICT denies on PDP unavailable (fail-closed)", + mode: pip.EMStrict, + expectedStatus: http.StatusForbidden, + expectedDecision: pip.DecisionDeny, + expectedError: pip.ErrorCodePDPUnavailable, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var capturedEvent gateway.PolicyEvent + callback := func(event gateway.PolicyEvent, req *pip.DecisionRequest) { + capturedEvent = event + } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: tc.mode, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + + mw.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + assert.Equal(t, tc.expectedDecision, capturedEvent.Decision) + assert.Equal(t, tc.expectedError, capturedEvent.ErrorCode) + }) + } +} + +func TestPolicyMiddleware_DecisionCaching(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "cache-test-001", + }, + } + + cache := pip.NewInMemoryCache() + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMDelegate, + DecisionCache: cache, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + // First request: should hit PDP + req1 := httptest.NewRequest("GET", "/v1/agents/abc", nil) + req1.Header.Set("X-Capiscio-Badge", ts.token) + rr1 := httptest.NewRecorder() + mw.ServeHTTP(rr1, req1) + + assert.Equal(t, http.StatusOK, rr1.Code) + assert.Equal(t, 1, pdp.callCount()) + + // Second request (same path): should hit cache, NOT PDP + var secondEvent gateway.PolicyEvent + callback := func(event gateway.PolicyEvent, req *pip.DecisionRequest) { + secondEvent = event + } + mw2 := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req2 := httptest.NewRequest("GET", "/v1/agents/abc", nil) + req2.Header.Set("X-Capiscio-Badge", ts.token) + rr2 := httptest.NewRecorder() + mw2.ServeHTTP(rr2, req2) + + assert.Equal(t, http.StatusOK, rr2.Code) + assert.Equal(t, 1, pdp.callCount(), "PDP should not be called again when cache hit") + assert.True(t, secondEvent.CacheHit) +} + +func TestPolicyMiddleware_CachedDenyBlocks(t *testing.T) { + ts := newTestSetup(t) + + // Setup a PDP that returns DENY, with DENY caching enabled + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionDeny, + DecisionID: "deny-cache-001", + }, + } + + cache := pip.NewInMemoryCache(pip.WithCacheDeny(true)) + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMDelegate, + DecisionCache: cache, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + // First request — hits PDP, gets DENY, caches it + req1 := httptest.NewRequest("GET", "/v1/deny-path", nil) + req1.Header.Set("X-Capiscio-Badge", ts.token) + rr1 := httptest.NewRecorder() + mw.ServeHTTP(rr1, req1) + assert.Equal(t, http.StatusForbidden, rr1.Code) + assert.Equal(t, 1, pdp.callCount()) + + // Second request — uses cache, PDP not called + var event gateway.PolicyEvent + callback := func(e gateway.PolicyEvent, _ *pip.DecisionRequest) { event = e } + mw2 := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req2 := httptest.NewRequest("GET", "/v1/deny-path", nil) + req2.Header.Set("X-Capiscio-Badge", ts.token) + rr2 := httptest.NewRecorder() + mw2.ServeHTTP(rr2, req2) + + assert.Equal(t, http.StatusForbidden, rr2.Code) + assert.Equal(t, 1, pdp.callCount(), "PDP should not be called when cache has DENY") + assert.True(t, event.CacheHit) + assert.Equal(t, pip.DecisionDeny, event.Decision) +} + +func TestPolicyMiddleware_Obligations(t *testing.T) { + ts := newTestSetup(t) + + t.Run("known obligation succeeds", func(t *testing.T) { + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-001", + Obligations: []pip.Obligation{{Type: "rate_limit", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + reg.Register(&mockObligationHandler{supported: "rate_limit"}) + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + ObligationReg: reg, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("obligation failure in EM-STRICT blocks", func(t *testing.T) { + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-002", + Obligations: []pip.Obligation{{Type: "rate_limit", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + reg.Register(&mockObligationHandler{supported: "rate_limit", err: fmt.Errorf("rate limit exceeded")}) + + var event gateway.PolicyEvent + callback := func(e gateway.PolicyEvent, _ *pip.DecisionRequest) { event = e } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + ObligationReg: reg, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + assert.Equal(t, pip.DecisionDeny, event.Decision) + }) + + t.Run("obligation failure in EM-OBSERVE allows through", func(t *testing.T) { + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-003", + Obligations: []pip.Obligation{{Type: "rate_limit", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + reg.Register(&mockObligationHandler{supported: "rate_limit", err: fmt.Errorf("rate limit exceeded")}) + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + ObligationReg: reg, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + // EM-OBSERVE: obligation failures are logged but not blocking + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("unknown obligation in EM-STRICT denies", func(t *testing.T) { + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-004", + Obligations: []pip.Obligation{{Type: "unknown_obligation", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + // No handler registered for "unknown_obligation" + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + ObligationReg: reg, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + }) +} + +func TestPolicyMiddleware_BreakGlass(t *testing.T) { + ts := newTestSetup(t) + + // Generate a separate key pair for break-glass (NOT the badge key!) + bgPub, bgPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionDeny, + DecisionID: "would-deny-001", + }, + } + + t.Run("valid break-glass bypasses PDP", func(t *testing.T) { + var event gateway.PolicyEvent + callback := func(e gateway.PolicyEvent, _ *pip.DecisionRequest) { event = e } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + BreakGlassKey: bgPub, + } + + bgToken := &pip.BreakGlassToken{ + JTI: "bg-001", + IAT: time.Now().Unix(), + EXP: time.Now().Add(5 * time.Minute).Unix(), + ISS: "root-admin", + SUB: "operator-alice", + Scope: pip.BreakGlassScope{Methods: []string{"*"}, Routes: []string{"*"}}, + Reason: "emergency: PDP outage investigation", + } + + compact := signBreakGlassToken(t, bgPriv, bgToken) + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + req.Header.Set("X-Capiscio-Breakglass", compact) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, 0, pdp.callCount(), "PDP should NOT be called when break-glass active") + assert.True(t, event.Override) + assert.Equal(t, "bg-001", event.OverrideJTI) + assert.Equal(t, pip.DecisionAllow, event.Decision) + }) + + t.Run("break-glass with wrong key is ignored", func(t *testing.T) { + _, wrongPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + BreakGlassKey: bgPub, + } + + bgToken := &pip.BreakGlassToken{ + JTI: "bg-bad", + IAT: time.Now().Unix(), + EXP: time.Now().Add(5 * time.Minute).Unix(), + ISS: "root-admin", + SUB: "operator-evil", + Scope: pip.BreakGlassScope{Methods: []string{"*"}, Routes: []string{"*"}}, + Reason: "trying to bypass", + } + + compact := signBreakGlassToken(t, wrongPriv, bgToken) + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + req.Header.Set("X-Capiscio-Breakglass", compact) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + // Wrong key = token ignored = falls through to PDP which DENYs + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("expired break-glass is ignored", func(t *testing.T) { + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + BreakGlassKey: bgPub, + } + + bgToken := &pip.BreakGlassToken{ + JTI: "bg-expired", + IAT: time.Now().Add(-10 * time.Minute).Unix(), + EXP: time.Now().Add(-5 * time.Minute).Unix(), // expired + ISS: "root-admin", + SUB: "operator-alice", + Scope: pip.BreakGlassScope{Methods: []string{"*"}, Routes: []string{"*"}}, + Reason: "expired token", + } + + compact := signBreakGlassToken(t, bgPriv, bgToken) + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + req.Header.Set("X-Capiscio-Breakglass", compact) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + + t.Run("break-glass scope mismatch is ignored", func(t *testing.T) { + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMStrict, + BreakGlassKey: bgPub, + } + + bgToken := &pip.BreakGlassToken{ + JTI: "bg-scoped", + IAT: time.Now().Unix(), + EXP: time.Now().Add(5 * time.Minute).Unix(), + ISS: "root-admin", + SUB: "operator-alice", + Scope: pip.BreakGlassScope{Methods: []string{"POST"}, Routes: []string{"/v1/different"}}, + Reason: "scoped override", + } + + compact := signBreakGlassToken(t, bgPriv, bgToken) + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + req.Header.Set("X-Capiscio-Breakglass", compact) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + // Scope doesn't match (GET != POST, /v1/agents != /v1/different) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) +} + +func TestPolicyMiddleware_TxnIDPropagation(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "txn-test-001", + }, + } + + t.Run("generates txn_id when absent", func(t *testing.T) { + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + } + + var capturedTxnID string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTxnID = r.Header.Get(pip.TxnIDHeader) + w.WriteHeader(http.StatusOK) + }) + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, next) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.NotEmpty(t, capturedTxnID, "txn_id should be generated and forwarded") + assert.Equal(t, capturedTxnID, pdp.lastRequest().Context.TxnID) + }) + + t.Run("reuses existing txn_id from header", func(t *testing.T) { + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + existingTxnID := "019471a2-1234-7abc-9def-abcdef123456" + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + req.Header.Set(pip.TxnIDHeader, existingTxnID) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, existingTxnID, pdp.lastRequest().Context.TxnID) + }) +} + +func TestPolicyMiddleware_EnvironmentAttrs(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "env-001", + }, + } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + PEPID: "pep-gateway-01", + Workspace: "acme-corp", + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + req := httptest.NewRequest("POST", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + pipReq := pdp.lastRequest() + require.NotNil(t, pipReq.Environment.PEPID) + assert.Equal(t, "pep-gateway-01", *pipReq.Environment.PEPID) + require.NotNil(t, pipReq.Environment.Workspace) + assert.Equal(t, "acme-corp", *pipReq.Environment.Workspace) + require.NotNil(t, pipReq.Environment.Time) + // Time should be parseable RFC3339 + _, err := time.Parse(time.RFC3339, *pipReq.Environment.Time) + assert.NoError(t, err) +} + +func TestPolicyMiddleware_NullEnvelopeFields(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "null-env-001", + }, + } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler()) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + pipReq := pdp.lastRequest() + // Badge-only mode: envelope fields MUST be null + assert.Nil(t, pipReq.Action.CapabilityClass) + assert.Nil(t, pipReq.Context.EnvelopeID) + assert.Nil(t, pipReq.Context.DelegationDepth) + assert.Nil(t, pipReq.Context.Constraints) + assert.Nil(t, pipReq.Context.ParentConstraints) + assert.Nil(t, pipReq.Context.HopID) +} + +func TestPolicyMiddleware_EventCallbackReceivesObligationTypes(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-event-001", + Obligations: []pip.Obligation{ + {Type: "rate_limit", Params: json.RawMessage(`{"rps": 100}`)}, + {Type: "enhanced_logging", Params: json.RawMessage(`{}`)}, + }, + }, + } + + var event gateway.PolicyEvent + callback := func(e gateway.PolicyEvent, _ *pip.DecisionRequest) { event = e } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, []string{"rate_limit", "enhanced_logging"}, event.Obligations) +} + +func TestPolicyMiddleware_PDPLatencyTracked(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "latency-001", + }, + } + + var event gateway.PolicyEvent + callback := func(e gateway.PolicyEvent, _ *pip.DecisionRequest) { event = e } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + } + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, okHandler(), callback) + + req := httptest.NewRequest("GET", "/v1/agents", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.GreaterOrEqual(t, event.PDPLatencyMs, int64(0)) +} + +func TestPolicyMiddleware_HeaderForwarding(t *testing.T) { + ts := newTestSetup(t) + + pdp := &mockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "header-001", + }, + } + + config := gateway.PEPConfig{ + PDPClient: pdp, + EnforcementMode: pip.EMObserve, + } + + var capturedSubject, capturedIssuer string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedSubject = r.Header.Get("X-Capiscio-Subject") + capturedIssuer = r.Header.Get("X-Capiscio-Issuer") + w.WriteHeader(http.StatusOK) + }) + + mw := gateway.NewPolicyMiddleware(ts.verifier, config, next) + + req := httptest.NewRequest("GET", "/v1/test", nil) + req.Header.Set("X-Capiscio-Badge", ts.token) + rr := httptest.NewRecorder() + mw.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, ts.claims.Subject, capturedSubject) + assert.Equal(t, ts.claims.Issuer, capturedIssuer) +} + +func TestParseBreakGlassJWS(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + t.Run("valid token", func(t *testing.T) { + bgToken := &pip.BreakGlassToken{ + JTI: "bg-parse-001", + IAT: time.Now().Unix(), + EXP: time.Now().Add(5 * time.Minute).Unix(), + ISS: "root-admin", + SUB: "operator-bob", + Scope: pip.BreakGlassScope{Methods: []string{"*"}, Routes: []string{"*"}}, + Reason: "testing parse", + } + + compact := signBreakGlassToken(t, priv, bgToken) + + parsed, err := pip.ParseBreakGlassJWS(compact, pub) + require.NoError(t, err) + assert.Equal(t, bgToken.JTI, parsed.JTI) + assert.Equal(t, bgToken.ISS, parsed.ISS) + assert.Equal(t, bgToken.SUB, parsed.SUB) + assert.Equal(t, bgToken.Reason, parsed.Reason) + }) + + t.Run("wrong key fails", func(t *testing.T) { + bgToken := &pip.BreakGlassToken{ + JTI: "bg-parse-002", + IAT: time.Now().Unix(), + EXP: time.Now().Add(5 * time.Minute).Unix(), + ISS: "root-admin", + SUB: "operator-bob", + Scope: pip.BreakGlassScope{Methods: []string{"*"}, Routes: []string{"*"}}, + Reason: "testing wrong key", + } + + compact := signBreakGlassToken(t, priv, bgToken) + + wrongPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + _, err = pip.ParseBreakGlassJWS(compact, wrongPub) + assert.Error(t, err) + }) + + t.Run("invalid compact JWS fails", func(t *testing.T) { + _, err := pip.ParseBreakGlassJWS("not-a-jws", pub) + assert.Error(t, err) + }) +} diff --git a/pkg/mcp/guard.go b/pkg/mcp/guard.go index b1a19a1..02d094b 100644 --- a/pkg/mcp/guard.go +++ b/pkg/mcp/guard.go @@ -4,18 +4,48 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "path" "time" "github.com/google/uuid" "github.com/capiscio/capiscio-core/v2/pkg/badge" + "github.com/capiscio/capiscio-core/v2/pkg/pip" ) // Guard implements RFC-006 tool access evaluation with atomic evidence emission. type Guard struct { badgeVerifier *badge.Verifier evidenceStore EvidenceStore + pdpClient pip.PDPClient + emMode pip.EnforcementMode + obligationReg *pip.ObligationRegistry + logger *slog.Logger +} + +// GuardOption configures optional Guard behavior. +type GuardOption func(*Guard) + +// WithPDPClient enables PDP-based policy evaluation (RFC-005). +// When set, the PDP replaces inline policy evaluation (trust level + allowed tools). +func WithPDPClient(client pip.PDPClient) GuardOption { + return func(g *Guard) { g.pdpClient = client } +} + +// WithEnforcementMode sets the enforcement mode. +func WithEnforcementMode(mode pip.EnforcementMode) GuardOption { + return func(g *Guard) { g.emMode = mode } +} + +// WithObligationRegistry sets the obligation registry for PDP obligations. +func WithObligationRegistry(reg *pip.ObligationRegistry) GuardOption { + return func(g *Guard) { g.obligationReg = reg } +} + +// WithGuardLogger sets the logger for the guard. +func WithGuardLogger(logger *slog.Logger) GuardOption { + return func(g *Guard) { g.logger = logger } } // EvidenceStore is the interface for storing evidence records @@ -31,20 +61,31 @@ func (n *NoOpEvidenceStore) Store(ctx context.Context, record EvidenceRecord) er return nil } -// NewGuard creates a new Guard instance -func NewGuard(badgeVerifier *badge.Verifier, evidenceStore EvidenceStore) *Guard { +// NewGuard creates a new Guard instance. +// Use GuardOption functions to configure PDP integration (RFC-005). +func NewGuard(badgeVerifier *badge.Verifier, evidenceStore EvidenceStore, opts ...GuardOption) *Guard { if evidenceStore == nil { evidenceStore = &NoOpEvidenceStore{} } - return &Guard{ + g := &Guard{ badgeVerifier: badgeVerifier, evidenceStore: evidenceStore, + emMode: pip.EMObserve, + logger: slog.Default(), + } + for _, opt := range opts { + opt(g) } + return g } // EvaluateToolAccess evaluates tool access and emits evidence atomically. // This implements RFC-006 §6.2-6.4. // +// When a PDPClient is configured (via WithPDPClient), the PDP is the authoritative +// decision source — inline policy (trust level + allowed tools) is skipped. +// When no PDPClient is configured, the inline policy is evaluated as before. +// // Key design principle: Single operation returns both decision and evidence // to avoid partial failures. func (g *Guard) EvaluateToolAccess( @@ -70,7 +111,7 @@ func (g *Guard) EvaluateToolAccess( EvidenceID: evidenceID, } - // 1. Derive identity from credential + // 1. Derive identity from credential (always — PDP doesn't replace authentication) agentDID, badgeJTI, trustLevel, err := g.deriveIdentity(ctx, credential, config) if err != nil { result.Decision = DecisionDeny @@ -82,23 +123,14 @@ func (g *Guard) EvaluateToolAccess( result.TrustLevel = trustLevel } - // 2. Check trust level against minimum - if result.Decision == DecisionAllow && trustLevel < config.MinTrustLevel { - result.Decision = DecisionDeny - result.DenyReason = DenyReasonTrustInsufficient - result.DenyDetail = fmt.Sprintf("trust level %d below minimum %d", trustLevel, config.MinTrustLevel) + // 2. Authorization path: PDP or inline policy + if result.Decision == DecisionAllow && g.pdpClient != nil { + g.evaluateWithPDP(ctx, result, toolName, agentDID, badgeJTI, trustLevel, config) + } else if result.Decision == DecisionAllow { + g.evaluateInlinePolicy(result, toolName, trustLevel, config) } - // 3. Check tool against allowed list (if configured) - if result.Decision == DecisionAllow && len(config.AllowedTools) > 0 { - if !g.isToolAllowed(toolName, config.AllowedTools) { - result.Decision = DecisionDeny - result.DenyReason = DenyReasonToolNotAllowed - result.DenyDetail = fmt.Sprintf("tool %q not in allowed list", toolName) - } - } - - // 4. Emit evidence (ALWAYS - both allow and deny) + // 3. Emit evidence (ALWAYS - both allow and deny) evidenceRecord := EvidenceRecord{ EventName: "capiscio.tool_invocation", AgentDID: result.AgentDID, @@ -132,6 +164,120 @@ func (g *Guard) EvaluateToolAccess( return result, nil } +// evaluateWithPDP queries the external PDP for an authorization decision. +// PDP replaces inline policy — it is the authoritative decision source. +func (g *Guard) evaluateWithPDP( + ctx context.Context, + result *EvaluateResult, + toolName, agentDID, badgeJTI string, + trustLevel int, + config *EvaluateConfig, +) { + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + txnID := uuid.Must(uuid.NewV7()).String() + + pipReq := &pip.DecisionRequest{ + PIPVersion: pip.PIPVersion, + Subject: pip.SubjectAttributes{ + DID: agentDID, + BadgeJTI: badgeJTI, + TrustLevel: fmt.Sprintf("%d", trustLevel), + }, + Action: pip.ActionAttributes{ + Operation: toolName, + }, + Resource: pip.ResourceAttributes{ + Identifier: toolName, + }, + Context: pip.ContextAttributes{ + TxnID: txnID, + EnforcementMode: g.emMode.String(), + }, + Environment: pip.EnvironmentAttrs{ + Time: &nowStr, + }, + } + + resp, err := g.pdpClient.Evaluate(ctx, pipReq) + + if err != nil { + // PDP unavailable — handle per enforcement mode (RFC-005 §7.4) + g.logger.ErrorContext(ctx, "PDP unavailable in MCP guard", + slog.String(pip.TelemetryErrorCode, pip.ErrorCodePDPUnavailable), + slog.String("error", err.Error()), + slog.String("enforcement_mode", g.emMode.String())) + + if g.emMode == pip.EMObserve { + // Shadow mode: allow through, log ALLOW_OBSERVE + result.PolicyDecision = pip.DecisionObserve + result.PolicyDecisionID = "pdp-unavailable" + return + } + // All other modes: fail-closed + result.Decision = DecisionDeny + result.DenyReason = DenyReasonPolicyDenied + result.DenyDetail = "policy service unavailable" + result.PolicyDecision = pip.DecisionDeny + result.PolicyDecisionID = "pdp-unavailable" + return + } + + result.PolicyDecisionID = resp.DecisionID + result.PolicyDecision = resp.Decision + + if resp.Decision == pip.DecisionDeny { + switch g.emMode { + case pip.EMObserve: + // Log but allow + g.logger.InfoContext(ctx, "PDP DENY in EM-OBSERVE (allowing)", + slog.String(pip.TelemetryDecisionID, resp.DecisionID)) + result.PolicyDecision = pip.DecisionObserve + default: + result.Decision = DecisionDeny + result.DenyReason = DenyReasonPolicyDenied + result.DenyDetail = resp.Reason + } + return + } + + // ALLOW — handle obligations + if g.obligationReg != nil && len(resp.Obligations) > 0 { + oblResult := g.obligationReg.Enforce(ctx, g.emMode, resp.Obligations) + if !oblResult.Proceed { + result.Decision = DecisionDeny + result.DenyReason = DenyReasonPolicyDenied + result.DenyDetail = "obligation enforcement failed" + result.PolicyDecision = pip.DecisionDeny + } + } +} + +// evaluateInlinePolicy runs the traditional trust level + tool glob checks. +func (g *Guard) evaluateInlinePolicy( + result *EvaluateResult, + toolName string, + trustLevel int, + config *EvaluateConfig, +) { + // Check trust level against minimum + if trustLevel < config.MinTrustLevel { + result.Decision = DecisionDeny + result.DenyReason = DenyReasonTrustInsufficient + result.DenyDetail = fmt.Sprintf("trust level %d below minimum %d", trustLevel, config.MinTrustLevel) + return + } + + // Check tool against allowed list (if configured) + if len(config.AllowedTools) > 0 { + if !g.isToolAllowed(toolName, config.AllowedTools) { + result.Decision = DecisionDeny + result.DenyReason = DenyReasonToolNotAllowed + result.DenyDetail = fmt.Sprintf("tool %q not in allowed list", toolName) + } + } +} + // deriveIdentity extracts identity information from the credential func (g *Guard) deriveIdentity( ctx context.Context, diff --git a/pkg/mcp/guard_pdp_test.go b/pkg/mcp/guard_pdp_test.go new file mode 100644 index 0000000..2049cda --- /dev/null +++ b/pkg/mcp/guard_pdp_test.go @@ -0,0 +1,533 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "testing" + + "github.com/capiscio/capiscio-core/v2/pkg/pip" +) + +// --- Mock PDP for Guard tests --- + +type guardMockPDP struct { + resp *pip.DecisionResponse + err error + mu sync.Mutex + reqs []*pip.DecisionRequest +} + +func (m *guardMockPDP) Evaluate(_ context.Context, req *pip.DecisionRequest) (*pip.DecisionResponse, error) { + m.mu.Lock() + m.reqs = append(m.reqs, req) + m.mu.Unlock() + return m.resp, m.err +} + +func (m *guardMockPDP) callCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.reqs) +} + +func (m *guardMockPDP) lastRequest() *pip.DecisionRequest { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.reqs) == 0 { + return nil + } + return m.reqs[len(m.reqs)-1] +} + +type guardMockOblHandler struct { + supported string + err error +} + +func (h *guardMockOblHandler) Handle(_ context.Context, _ pip.Obligation) error { + return h.err +} + +func (h *guardMockOblHandler) Supports(t string) bool { + return t == h.supported +} + +// --- PDP Integration Tests --- + +func TestGuard_WithPDP_Allow(t *testing.T) { + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "pdp-allow-001", + }, + } + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMDelegate), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "read_file", + "sha256:abc123", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionAllow { + t.Errorf("Decision = %v, want ALLOW", result.Decision) + } + if result.PolicyDecisionID != "pdp-allow-001" { + t.Errorf("PolicyDecisionID = %q, want %q", result.PolicyDecisionID, "pdp-allow-001") + } + if result.PolicyDecision != pip.DecisionAllow { + t.Errorf("PolicyDecision = %q, want %q", result.PolicyDecision, pip.DecisionAllow) + } + if pdp.callCount() != 1 { + t.Errorf("PDP call count = %d, want 1", pdp.callCount()) + } + + // Verify PIP request structure + pipReq := pdp.lastRequest() + if pipReq.PIPVersion != pip.PIPVersion { + t.Errorf("PIPVersion = %q, want %q", pipReq.PIPVersion, pip.PIPVersion) + } + if pipReq.Action.Operation != "read_file" { + t.Errorf("Action.Operation = %q, want %q", pipReq.Action.Operation, "read_file") + } + if pipReq.Context.EnforcementMode != pip.EMDelegate.String() { + t.Errorf("EM = %q, want %q", pipReq.Context.EnforcementMode, pip.EMDelegate.String()) + } + if pipReq.Context.TxnID == "" { + t.Error("TxnID should be generated") + } +} + +func TestGuard_WithPDP_Deny(t *testing.T) { + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionDeny, + DecisionID: "pdp-deny-001", + Reason: "tool not permitted by policy", + }, + } + + tests := []struct { + name string + mode pip.EnforcementMode + wantDecision Decision + wantPolicy string + }{ + {"EM-OBSERVE allows through", pip.EMObserve, DecisionAllow, pip.DecisionObserve}, + {"EM-GUARD blocks", pip.EMGuard, DecisionDeny, pip.DecisionDeny}, + {"EM-DELEGATE blocks", pip.EMDelegate, DecisionDeny, pip.DecisionDeny}, + {"EM-STRICT blocks", pip.EMStrict, DecisionDeny, pip.DecisionDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(tc.mode), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "dangerous_tool", + "sha256:xyz", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != tc.wantDecision { + t.Errorf("Decision = %v, want %v", result.Decision, tc.wantDecision) + } + if result.PolicyDecision != tc.wantPolicy { + t.Errorf("PolicyDecision = %q, want %q", result.PolicyDecision, tc.wantPolicy) + } + }) + } +} + +func TestGuard_WithPDP_Unavailable(t *testing.T) { + pdp := &guardMockPDP{ + err: fmt.Errorf("connection refused"), + } + + tests := []struct { + name string + mode pip.EnforcementMode + wantDecision Decision + wantPolicy string + }{ + {"EM-OBSERVE allows on PDP unavailable", pip.EMObserve, DecisionAllow, pip.DecisionObserve}, + {"EM-GUARD fails closed", pip.EMGuard, DecisionDeny, pip.DecisionDeny}, + {"EM-DELEGATE fails closed", pip.EMDelegate, DecisionDeny, pip.DecisionDeny}, + {"EM-STRICT fails closed", pip.EMStrict, DecisionDeny, pip.DecisionDeny}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(tc.mode), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "read_file", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != tc.wantDecision { + t.Errorf("Decision = %v, want %v", result.Decision, tc.wantDecision) + } + if result.PolicyDecision != tc.wantPolicy { + t.Errorf("PolicyDecision = %q, want %q", result.PolicyDecision, tc.wantPolicy) + } + if result.PolicyDecisionID != "pdp-unavailable" { + t.Errorf("PolicyDecisionID = %q, want %q", result.PolicyDecisionID, "pdp-unavailable") + } + }) + } +} + +func TestGuard_WithPDP_SkipsInlinePolicy(t *testing.T) { + // When PDP is configured, inline policy (trust level + allowed tools) is skipped. + // Even if inline policy would DENY, PDP's ALLOW is authoritative. + // NOTE: Authentication (badge required) still runs before PDP — only + // the authorization checks (trust level comparison, tool allowlist) are replaced. + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "pdp-override-inline", + }, + } + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMDelegate), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "forbidden_tool", // would be denied by inline AllowedTools + "sha256:xyz", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{ + AcceptLevelZero: true, // let anonymous pass authentication + MinTrustLevel: 3, // inline would deny (trust 0 < 3) + AllowedTools: []string{"safe_tool_*"}, // forbidden_tool not in allowed list + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionAllow { + t.Errorf("Decision = %v, want ALLOW (PDP should override inline policy)", result.Decision) + } + if pdp.callCount() != 1 { + t.Errorf("PDP should have been called once, got %d", pdp.callCount()) + } +} + +func TestGuard_WithPDP_AuthStillRequired(t *testing.T) { + // Even with PDP, authentication failures (badge required but missing) + // are enforced BEFORE the PDP is consulted. + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "should-not-be-called", + }, + } + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMDelegate), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "any_tool", + "sha256:xyz", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{ + MinTrustLevel: 1, // requires badge + AcceptLevelZero: false, // anonymous NOT accepted + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionDeny { + t.Errorf("Decision = %v, want DENY (auth should block before PDP)", result.Decision) + } + if result.DenyReason != DenyReasonBadgeMissing { + t.Errorf("DenyReason = %v, want BADGE_MISSING", result.DenyReason) + } + if pdp.callCount() != 0 { + t.Errorf("PDP should NOT have been called, got %d", pdp.callCount()) + } +} + +func TestGuard_WithPDP_ObligationsSucceed(t *testing.T) { + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-ok-001", + Obligations: []pip.Obligation{{Type: "rate_limit", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + reg.Register(&guardMockOblHandler{supported: "rate_limit"}) + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMStrict), + WithObligationRegistry(reg), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "tool_with_obligations", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionAllow { + t.Errorf("Decision = %v, want ALLOW", result.Decision) + } +} + +func TestGuard_WithPDP_ObligationFailureInStrict(t *testing.T) { + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-fail-001", + Obligations: []pip.Obligation{{Type: "rate_limit", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + reg.Register(&guardMockOblHandler{supported: "rate_limit", err: fmt.Errorf("rate exceeded")}) + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMStrict), + WithObligationRegistry(reg), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "tool_with_obligations", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionDeny { + t.Errorf("Decision = %v, want DENY (obligation failure in EM-STRICT)", result.Decision) + } + if result.DenyReason != DenyReasonPolicyDenied { + t.Errorf("DenyReason = %v, want POLICY_DENIED", result.DenyReason) + } +} + +func TestGuard_WithPDP_UnknownObligationInStrict(t *testing.T) { + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionAllow, + DecisionID: "obl-unknown-001", + Obligations: []pip.Obligation{{Type: "unknown_type", Params: json.RawMessage(`{}`)}}, + }, + } + + reg := pip.NewObligationRegistry(slog.Default()) + // No handler registered for "unknown_type" + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMStrict), + WithObligationRegistry(reg), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "tool_unknown_obl", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionDeny { + t.Errorf("Decision = %v, want DENY (unknown obligation in EM-STRICT)", result.Decision) + } +} + +func TestGuard_NoPDP_InlinePolicyStillWorks(t *testing.T) { + // When no PDP is configured, inline policy should function as before + guard := NewGuard(nil, nil) // no PDP, no options + + t.Run("trust level check", func(t *testing.T) { + result, err := guard.EvaluateToolAccess( + context.Background(), + "read_tool", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{ + MinTrustLevel: 2, + AcceptLevelZero: true, + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionDeny { + t.Errorf("Decision = %v, want DENY (trust level 0 < min 2)", result.Decision) + } + if result.DenyReason != DenyReasonTrustInsufficient { + t.Errorf("DenyReason = %v, want TRUST_INSUFFICIENT", result.DenyReason) + } + }) + + t.Run("allowed tools check", func(t *testing.T) { + result, err := guard.EvaluateToolAccess( + context.Background(), + "forbidden_tool", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{ + AllowedTools: []string{"safe_*"}, + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionDeny { + t.Errorf("Decision = %v, want DENY", result.Decision) + } + if result.DenyReason != DenyReasonToolNotAllowed { + t.Errorf("DenyReason = %v, want TOOL_NOT_ALLOWED", result.DenyReason) + } + }) + + t.Run("allowed tool pattern matches", func(t *testing.T) { + result, err := guard.EvaluateToolAccess( + context.Background(), + "safe_read", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{ + AllowedTools: []string{"safe_*"}, + }, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionAllow { + t.Errorf("Decision = %v, want ALLOW", result.Decision) + } + }) +} + +func TestGuard_WithPDP_EvidenceAlwaysEmitted(t *testing.T) { + // Evidence should be emitted for both ALLOW and DENY via PDP + pdp := &guardMockPDP{ + resp: &pip.DecisionResponse{ + Decision: pip.DecisionDeny, + DecisionID: "evidence-deny-001", + Reason: "denied by policy", + }, + } + + guard := NewGuard(nil, nil, + WithPDPClient(pdp), + WithEnforcementMode(pip.EMStrict), + ) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "risky_tool", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionDeny { + t.Errorf("Decision = %v, want DENY", result.Decision) + } + if result.EvidenceJSON == "" { + t.Error("EvidenceJSON should not be empty even for DENY") + } + if result.EvidenceID == "" { + t.Error("EvidenceID should not be empty") + } +} + +func TestGuard_WithGuardLogger(t *testing.T) { + logger := slog.Default() + + guard := NewGuard(nil, nil, WithGuardLogger(logger)) + + result, err := guard.EvaluateToolAccess( + context.Background(), + "test_tool", + "sha256:abc", + "https://example.com", + NewAnonymousCredential(), + &EvaluateConfig{}, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Decision != DecisionAllow { + t.Errorf("Decision = %v, want ALLOW", result.Decision) + } +} diff --git a/pkg/mcp/types.go b/pkg/mcp/types.go index fbf3aa4..57bf0b3 100644 --- a/pkg/mcp/types.go +++ b/pkg/mcp/types.go @@ -180,6 +180,12 @@ type EvaluateResult struct { // Timestamp is when the evaluation occurred Timestamp time.Time + + // PolicyDecisionID is the PDP decision ID (RFC-005, only set when PDP is configured) + PolicyDecisionID string + + // PolicyDecision is the PDP decision string: ALLOW, DENY, or ALLOW_OBSERVE (RFC-005) + PolicyDecision string } // VerifyConfig holds configuration for server identity verification diff --git a/pkg/pip/breakglass.go b/pkg/pip/breakglass.go index b4f14a6..bd3d360 100644 --- a/pkg/pip/breakglass.go +++ b/pkg/pip/breakglass.go @@ -2,8 +2,11 @@ package pip import ( "crypto" + "encoding/json" "fmt" "time" + + "github.com/go-jose/go-jose/v4" ) // BreakGlassToken represents a break-glass override token (RFC-005 §9). @@ -123,4 +126,25 @@ func (v *BreakGlassValidator) PublicKey() crypto.PublicKey { return v.publicKey } +// ParseBreakGlassJWS verifies a compact JWS break-glass token and extracts claims. +// The publicKey MUST be the dedicated break-glass key, not the CA badge-signing key. +func ParseBreakGlassJWS(compact string, publicKey crypto.PublicKey) (*BreakGlassToken, error) { + jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.EdDSA, jose.ES256}) + if err != nil { + return nil, fmt.Errorf("breakglass: parse JWS: %w", err) + } + + payload, err := jws.Verify(publicKey) + if err != nil { + return nil, fmt.Errorf("breakglass: verify signature: %w", err) + } + + var token BreakGlassToken + if err := json.Unmarshal(payload, &token); err != nil { + return nil, fmt.Errorf("breakglass: unmarshal claims: %w", err) + } + + return &token, nil +} + diff --git a/pkg/rpc/gen/capiscio/v1/mcp.pb.go b/pkg/rpc/gen/capiscio/v1/mcp.pb.go index 90e6be4..7f7b570 100644 --- a/pkg/rpc/gen/capiscio/v1/mcp.pb.go +++ b/pkg/rpc/gen/capiscio/v1/mcp.pb.go @@ -346,8 +346,16 @@ type EvaluateToolAccessRequest struct { // Optional policy configuration PolicyVersion string `protobuf:"bytes,6,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` Config *EvaluateConfig `protobuf:"bytes,7,opt,name=config,proto3" json:"config,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // RFC-005: PDP integration context (badge-only mode: all empty/zero) + EnforcementMode string `protobuf:"bytes,8,opt,name=enforcement_mode,json=enforcementMode,proto3" json:"enforcement_mode,omitempty"` // EM-OBSERVE, EM-GUARD, EM-DELEGATE, EM-STRICT + // RFC-008: Authority Envelope context (future, all empty for now) + CapabilityClass string `protobuf:"bytes,10,opt,name=capability_class,json=capabilityClass,proto3" json:"capability_class,omitempty"` // reserved for envelope + EnvelopeId string `protobuf:"bytes,11,opt,name=envelope_id,json=envelopeId,proto3" json:"envelope_id,omitempty"` // reserved for envelope + DelegationDepth int32 `protobuf:"varint,12,opt,name=delegation_depth,json=delegationDepth,proto3" json:"delegation_depth,omitempty"` // reserved for envelope + ConstraintsJson string `protobuf:"bytes,13,opt,name=constraints_json,json=constraintsJson,proto3" json:"constraints_json,omitempty"` // reserved for envelope + ParentConstraintsJson string `protobuf:"bytes,14,opt,name=parent_constraints_json,json=parentConstraintsJson,proto3" json:"parent_constraints_json,omitempty"` // reserved for envelope + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EvaluateToolAccessRequest) Reset() { @@ -440,6 +448,48 @@ func (x *EvaluateToolAccessRequest) GetConfig() *EvaluateConfig { return nil } +func (x *EvaluateToolAccessRequest) GetEnforcementMode() string { + if x != nil { + return x.EnforcementMode + } + return "" +} + +func (x *EvaluateToolAccessRequest) GetCapabilityClass() string { + if x != nil { + return x.CapabilityClass + } + return "" +} + +func (x *EvaluateToolAccessRequest) GetEnvelopeId() string { + if x != nil { + return x.EnvelopeId + } + return "" +} + +func (x *EvaluateToolAccessRequest) GetDelegationDepth() int32 { + if x != nil { + return x.DelegationDepth + } + return 0 +} + +func (x *EvaluateToolAccessRequest) GetConstraintsJson() string { + if x != nil { + return x.ConstraintsJson + } + return "" +} + +func (x *EvaluateToolAccessRequest) GetParentConstraintsJson() string { + if x != nil { + return x.ParentConstraintsJson + } + return "" +} + type isEvaluateToolAccessRequest_CallerCredential interface { isEvaluateToolAccessRequest_CallerCredential() } @@ -549,9 +599,14 @@ type EvaluateToolAccessResponse struct { // Unique evidence record ID EvidenceId string `protobuf:"bytes,9,opt,name=evidence_id,json=evidenceId,proto3" json:"evidence_id,omitempty"` // Timestamp of evaluation - Timestamp *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Timestamp *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + // RFC-005: Policy decision context + PolicyDecisionId string `protobuf:"bytes,11,opt,name=policy_decision_id,json=policyDecisionId,proto3" json:"policy_decision_id,omitempty"` // from PDP response + PolicyDecision string `protobuf:"bytes,12,opt,name=policy_decision,json=policyDecision,proto3" json:"policy_decision,omitempty"` // ALLOW, DENY, or ALLOW_OBSERVE + EnforcementMode string `protobuf:"bytes,13,opt,name=enforcement_mode,json=enforcementMode,proto3" json:"enforcement_mode,omitempty"` // mode used for this evaluation + Obligations []*MCPObligation `protobuf:"bytes,14,rep,name=obligations,proto3" json:"obligations,omitempty"` // obligations from PDP + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EvaluateToolAccessResponse) Reset() { @@ -654,6 +709,87 @@ func (x *EvaluateToolAccessResponse) GetTimestamp() *timestamppb.Timestamp { return nil } +func (x *EvaluateToolAccessResponse) GetPolicyDecisionId() string { + if x != nil { + return x.PolicyDecisionId + } + return "" +} + +func (x *EvaluateToolAccessResponse) GetPolicyDecision() string { + if x != nil { + return x.PolicyDecision + } + return "" +} + +func (x *EvaluateToolAccessResponse) GetEnforcementMode() string { + if x != nil { + return x.EnforcementMode + } + return "" +} + +func (x *EvaluateToolAccessResponse) GetObligations() []*MCPObligation { + if x != nil { + return x.Obligations + } + return nil +} + +// Obligation returned by PDP (RFC-005 §7.1) +type MCPObligation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + ParamsJson string `protobuf:"bytes,2,opt,name=params_json,json=paramsJson,proto3" json:"params_json,omitempty"` // opaque JSON + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MCPObligation) Reset() { + *x = MCPObligation{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MCPObligation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MCPObligation) ProtoMessage() {} + +func (x *MCPObligation) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MCPObligation.ProtoReflect.Descriptor instead. +func (*MCPObligation) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{3} +} + +func (x *MCPObligation) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *MCPObligation) GetParamsJson() string { + if x != nil { + return x.ParamsJson + } + return "" +} + // Request to verify server identity type VerifyServerIdentityRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -673,7 +809,7 @@ type VerifyServerIdentityRequest struct { func (x *VerifyServerIdentityRequest) Reset() { *x = VerifyServerIdentityRequest{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[3] + mi := &file_capiscio_v1_mcp_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -685,7 +821,7 @@ func (x *VerifyServerIdentityRequest) String() string { func (*VerifyServerIdentityRequest) ProtoMessage() {} func (x *VerifyServerIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[3] + mi := &file_capiscio_v1_mcp_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -698,7 +834,7 @@ func (x *VerifyServerIdentityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyServerIdentityRequest.ProtoReflect.Descriptor instead. func (*VerifyServerIdentityRequest) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{3} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{4} } func (x *VerifyServerIdentityRequest) GetServerDid() string { @@ -755,7 +891,7 @@ type MCPVerifyConfig struct { func (x *MCPVerifyConfig) Reset() { *x = MCPVerifyConfig{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[4] + mi := &file_capiscio_v1_mcp_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -767,7 +903,7 @@ func (x *MCPVerifyConfig) String() string { func (*MCPVerifyConfig) ProtoMessage() {} func (x *MCPVerifyConfig) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[4] + mi := &file_capiscio_v1_mcp_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -780,7 +916,7 @@ func (x *MCPVerifyConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPVerifyConfig.ProtoReflect.Descriptor instead. func (*MCPVerifyConfig) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{4} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{5} } func (x *MCPVerifyConfig) GetTrustedIssuers() []string { @@ -839,7 +975,7 @@ type VerifyServerIdentityResponse struct { func (x *VerifyServerIdentityResponse) Reset() { *x = VerifyServerIdentityResponse{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[5] + mi := &file_capiscio_v1_mcp_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -851,7 +987,7 @@ func (x *VerifyServerIdentityResponse) String() string { func (*VerifyServerIdentityResponse) ProtoMessage() {} func (x *VerifyServerIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[5] + mi := &file_capiscio_v1_mcp_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -864,7 +1000,7 @@ func (x *VerifyServerIdentityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyServerIdentityResponse.ProtoReflect.Descriptor instead. func (*VerifyServerIdentityResponse) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{5} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{6} } func (x *VerifyServerIdentityResponse) GetState() MCPServerState { @@ -923,7 +1059,7 @@ type ParseServerIdentityRequest struct { func (x *ParseServerIdentityRequest) Reset() { *x = ParseServerIdentityRequest{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[6] + mi := &file_capiscio_v1_mcp_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -935,7 +1071,7 @@ func (x *ParseServerIdentityRequest) String() string { func (*ParseServerIdentityRequest) ProtoMessage() {} func (x *ParseServerIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[6] + mi := &file_capiscio_v1_mcp_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -948,7 +1084,7 @@ func (x *ParseServerIdentityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ParseServerIdentityRequest.ProtoReflect.Descriptor instead. func (*ParseServerIdentityRequest) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{6} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{7} } func (x *ParseServerIdentityRequest) GetSource() isParseServerIdentityRequest_Source { @@ -1003,7 +1139,7 @@ type MCPHttpHeaders struct { func (x *MCPHttpHeaders) Reset() { *x = MCPHttpHeaders{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[7] + mi := &file_capiscio_v1_mcp_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1015,7 +1151,7 @@ func (x *MCPHttpHeaders) String() string { func (*MCPHttpHeaders) ProtoMessage() {} func (x *MCPHttpHeaders) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[7] + mi := &file_capiscio_v1_mcp_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1028,7 +1164,7 @@ func (x *MCPHttpHeaders) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPHttpHeaders.ProtoReflect.Descriptor instead. func (*MCPHttpHeaders) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{7} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{8} } func (x *MCPHttpHeaders) GetCapiscioServerDid() string { @@ -1056,7 +1192,7 @@ type MCPJsonRpcMeta struct { func (x *MCPJsonRpcMeta) Reset() { *x = MCPJsonRpcMeta{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[8] + mi := &file_capiscio_v1_mcp_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1068,7 +1204,7 @@ func (x *MCPJsonRpcMeta) String() string { func (*MCPJsonRpcMeta) ProtoMessage() {} func (x *MCPJsonRpcMeta) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[8] + mi := &file_capiscio_v1_mcp_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1081,7 +1217,7 @@ func (x *MCPJsonRpcMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPJsonRpcMeta.ProtoReflect.Descriptor instead. func (*MCPJsonRpcMeta) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{8} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{9} } func (x *MCPJsonRpcMeta) GetMetaJson() string { @@ -1106,7 +1242,7 @@ type ParseServerIdentityResponse struct { func (x *ParseServerIdentityResponse) Reset() { *x = ParseServerIdentityResponse{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[9] + mi := &file_capiscio_v1_mcp_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1118,7 +1254,7 @@ func (x *ParseServerIdentityResponse) String() string { func (*ParseServerIdentityResponse) ProtoMessage() {} func (x *ParseServerIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[9] + mi := &file_capiscio_v1_mcp_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1131,7 +1267,7 @@ func (x *ParseServerIdentityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ParseServerIdentityResponse.ProtoReflect.Descriptor instead. func (*ParseServerIdentityResponse) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{9} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{10} } func (x *ParseServerIdentityResponse) GetServerDid() string { @@ -1166,7 +1302,7 @@ type MCPHealthRequest struct { func (x *MCPHealthRequest) Reset() { *x = MCPHealthRequest{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[10] + mi := &file_capiscio_v1_mcp_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1178,7 +1314,7 @@ func (x *MCPHealthRequest) String() string { func (*MCPHealthRequest) ProtoMessage() {} func (x *MCPHealthRequest) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[10] + mi := &file_capiscio_v1_mcp_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1191,7 +1327,7 @@ func (x *MCPHealthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPHealthRequest.ProtoReflect.Descriptor instead. func (*MCPHealthRequest) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{10} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{11} } func (x *MCPHealthRequest) GetClientVersion() string { @@ -1218,7 +1354,7 @@ type MCPHealthResponse struct { func (x *MCPHealthResponse) Reset() { *x = MCPHealthResponse{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[11] + mi := &file_capiscio_v1_mcp_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1230,7 +1366,7 @@ func (x *MCPHealthResponse) String() string { func (*MCPHealthResponse) ProtoMessage() {} func (x *MCPHealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[11] + mi := &file_capiscio_v1_mcp_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1243,7 +1379,7 @@ func (x *MCPHealthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPHealthResponse.ProtoReflect.Descriptor instead. func (*MCPHealthResponse) Descriptor() ([]byte, []int) { - return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{11} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{12} } func (x *MCPHealthResponse) GetHealthy() bool { @@ -1278,7 +1414,7 @@ var File_capiscio_v1_mcp_proto protoreflect.FileDescriptor const file_capiscio_v1_mcp_proto_rawDesc = "" + "\n" + - "\x15capiscio/v1/mcp.proto\x12\vcapiscio.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa9\x02\n" + + "\x15capiscio/v1/mcp.proto\x12\vcapiscio.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb4\x04\n" + "\x19EvaluateToolAccessRequest\x12\x1b\n" + "\ttool_name\x18\x01 \x01(\tR\btoolName\x12\x1f\n" + "\vparams_hash\x18\x02 \x01(\tR\n" + @@ -1287,13 +1423,22 @@ const file_capiscio_v1_mcp_proto_rawDesc = "" + "\tbadge_jws\x18\x04 \x01(\tH\x00R\bbadgeJws\x12\x19\n" + "\aapi_key\x18\x05 \x01(\tH\x00R\x06apiKey\x12%\n" + "\x0epolicy_version\x18\x06 \x01(\tR\rpolicyVersion\x123\n" + - "\x06config\x18\a \x01(\v2\x1b.capiscio.v1.EvaluateConfigR\x06configB\x13\n" + - "\x11caller_credential\"\xb2\x01\n" + + "\x06config\x18\a \x01(\v2\x1b.capiscio.v1.EvaluateConfigR\x06config\x12)\n" + + "\x10enforcement_mode\x18\b \x01(\tR\x0fenforcementMode\x12)\n" + + "\x10capability_class\x18\n" + + " \x01(\tR\x0fcapabilityClass\x12\x1f\n" + + "\venvelope_id\x18\v \x01(\tR\n" + + "envelopeId\x12)\n" + + "\x10delegation_depth\x18\f \x01(\x05R\x0fdelegationDepth\x12)\n" + + "\x10constraints_json\x18\r \x01(\tR\x0fconstraintsJson\x126\n" + + "\x17parent_constraints_json\x18\x0e \x01(\tR\x15parentConstraintsJsonB\x13\n" + + "\x11caller_credentialJ\x04\b\t\x10\n" + + "\"\xb2\x01\n" + "\x0eEvaluateConfig\x12'\n" + "\x0ftrusted_issuers\x18\x01 \x03(\tR\x0etrustedIssuers\x12&\n" + "\x0fmin_trust_level\x18\x02 \x01(\x05R\rminTrustLevel\x12*\n" + "\x11accept_level_zero\x18\x03 \x01(\bR\x0facceptLevelZero\x12#\n" + - "\rallowed_tools\x18\x04 \x03(\tR\fallowedTools\"\xc5\x03\n" + + "\rallowed_tools\x18\x04 \x03(\tR\fallowedTools\"\x85\x05\n" + "\x1aEvaluateToolAccessResponse\x124\n" + "\bdecision\x18\x01 \x01(\x0e2\x18.capiscio.v1.MCPDecisionR\bdecision\x12;\n" + "\vdeny_reason\x18\x02 \x01(\x0e2\x1a.capiscio.v1.MCPDenyReasonR\n" + @@ -1310,7 +1455,15 @@ const file_capiscio_v1_mcp_proto_rawDesc = "" + "\vevidence_id\x18\t \x01(\tR\n" + "evidenceId\x128\n" + "\ttimestamp\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"\xe5\x01\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12,\n" + + "\x12policy_decision_id\x18\v \x01(\tR\x10policyDecisionId\x12'\n" + + "\x0fpolicy_decision\x18\f \x01(\tR\x0epolicyDecision\x12)\n" + + "\x10enforcement_mode\x18\r \x01(\tR\x0fenforcementMode\x12<\n" + + "\vobligations\x18\x0e \x03(\v2\x1a.capiscio.v1.MCPObligationR\vobligations\"D\n" + + "\rMCPObligation\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x1f\n" + + "\vparams_json\x18\x02 \x01(\tR\n" + + "paramsJson\"\xe5\x01\n" + "\x1bVerifyServerIdentityRequest\x12\x1d\n" + "\n" + "server_did\x18\x01 \x01(\tR\tserverDid\x12!\n" + @@ -1410,7 +1563,7 @@ func file_capiscio_v1_mcp_proto_rawDescGZIP() []byte { } var file_capiscio_v1_mcp_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_capiscio_v1_mcp_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_capiscio_v1_mcp_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_capiscio_v1_mcp_proto_goTypes = []any{ (MCPDecision)(0), // 0: capiscio.v1.MCPDecision (MCPAuthLevel)(0), // 1: capiscio.v1.MCPAuthLevel @@ -1420,41 +1573,43 @@ var file_capiscio_v1_mcp_proto_goTypes = []any{ (*EvaluateToolAccessRequest)(nil), // 5: capiscio.v1.EvaluateToolAccessRequest (*EvaluateConfig)(nil), // 6: capiscio.v1.EvaluateConfig (*EvaluateToolAccessResponse)(nil), // 7: capiscio.v1.EvaluateToolAccessResponse - (*VerifyServerIdentityRequest)(nil), // 8: capiscio.v1.VerifyServerIdentityRequest - (*MCPVerifyConfig)(nil), // 9: capiscio.v1.MCPVerifyConfig - (*VerifyServerIdentityResponse)(nil), // 10: capiscio.v1.VerifyServerIdentityResponse - (*ParseServerIdentityRequest)(nil), // 11: capiscio.v1.ParseServerIdentityRequest - (*MCPHttpHeaders)(nil), // 12: capiscio.v1.MCPHttpHeaders - (*MCPJsonRpcMeta)(nil), // 13: capiscio.v1.MCPJsonRpcMeta - (*ParseServerIdentityResponse)(nil), // 14: capiscio.v1.ParseServerIdentityResponse - (*MCPHealthRequest)(nil), // 15: capiscio.v1.MCPHealthRequest - (*MCPHealthResponse)(nil), // 16: capiscio.v1.MCPHealthResponse - (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (*MCPObligation)(nil), // 8: capiscio.v1.MCPObligation + (*VerifyServerIdentityRequest)(nil), // 9: capiscio.v1.VerifyServerIdentityRequest + (*MCPVerifyConfig)(nil), // 10: capiscio.v1.MCPVerifyConfig + (*VerifyServerIdentityResponse)(nil), // 11: capiscio.v1.VerifyServerIdentityResponse + (*ParseServerIdentityRequest)(nil), // 12: capiscio.v1.ParseServerIdentityRequest + (*MCPHttpHeaders)(nil), // 13: capiscio.v1.MCPHttpHeaders + (*MCPJsonRpcMeta)(nil), // 14: capiscio.v1.MCPJsonRpcMeta + (*ParseServerIdentityResponse)(nil), // 15: capiscio.v1.ParseServerIdentityResponse + (*MCPHealthRequest)(nil), // 16: capiscio.v1.MCPHealthRequest + (*MCPHealthResponse)(nil), // 17: capiscio.v1.MCPHealthResponse + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp } var file_capiscio_v1_mcp_proto_depIdxs = []int32{ 6, // 0: capiscio.v1.EvaluateToolAccessRequest.config:type_name -> capiscio.v1.EvaluateConfig 0, // 1: capiscio.v1.EvaluateToolAccessResponse.decision:type_name -> capiscio.v1.MCPDecision 2, // 2: capiscio.v1.EvaluateToolAccessResponse.deny_reason:type_name -> capiscio.v1.MCPDenyReason 1, // 3: capiscio.v1.EvaluateToolAccessResponse.auth_level:type_name -> capiscio.v1.MCPAuthLevel - 17, // 4: capiscio.v1.EvaluateToolAccessResponse.timestamp:type_name -> google.protobuf.Timestamp - 9, // 5: capiscio.v1.VerifyServerIdentityRequest.config:type_name -> capiscio.v1.MCPVerifyConfig - 3, // 6: capiscio.v1.VerifyServerIdentityResponse.state:type_name -> capiscio.v1.MCPServerState - 4, // 7: capiscio.v1.VerifyServerIdentityResponse.error_code:type_name -> capiscio.v1.MCPServerErrorCode - 12, // 8: capiscio.v1.ParseServerIdentityRequest.http_headers:type_name -> capiscio.v1.MCPHttpHeaders - 13, // 9: capiscio.v1.ParseServerIdentityRequest.jsonrpc_meta:type_name -> capiscio.v1.MCPJsonRpcMeta - 5, // 10: capiscio.v1.MCPService.EvaluateToolAccess:input_type -> capiscio.v1.EvaluateToolAccessRequest - 8, // 11: capiscio.v1.MCPService.VerifyServerIdentity:input_type -> capiscio.v1.VerifyServerIdentityRequest - 11, // 12: capiscio.v1.MCPService.ParseServerIdentity:input_type -> capiscio.v1.ParseServerIdentityRequest - 15, // 13: capiscio.v1.MCPService.Health:input_type -> capiscio.v1.MCPHealthRequest - 7, // 14: capiscio.v1.MCPService.EvaluateToolAccess:output_type -> capiscio.v1.EvaluateToolAccessResponse - 10, // 15: capiscio.v1.MCPService.VerifyServerIdentity:output_type -> capiscio.v1.VerifyServerIdentityResponse - 14, // 16: capiscio.v1.MCPService.ParseServerIdentity:output_type -> capiscio.v1.ParseServerIdentityResponse - 16, // 17: capiscio.v1.MCPService.Health:output_type -> capiscio.v1.MCPHealthResponse - 14, // [14:18] is the sub-list for method output_type - 10, // [10:14] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 18, // 4: capiscio.v1.EvaluateToolAccessResponse.timestamp:type_name -> google.protobuf.Timestamp + 8, // 5: capiscio.v1.EvaluateToolAccessResponse.obligations:type_name -> capiscio.v1.MCPObligation + 10, // 6: capiscio.v1.VerifyServerIdentityRequest.config:type_name -> capiscio.v1.MCPVerifyConfig + 3, // 7: capiscio.v1.VerifyServerIdentityResponse.state:type_name -> capiscio.v1.MCPServerState + 4, // 8: capiscio.v1.VerifyServerIdentityResponse.error_code:type_name -> capiscio.v1.MCPServerErrorCode + 13, // 9: capiscio.v1.ParseServerIdentityRequest.http_headers:type_name -> capiscio.v1.MCPHttpHeaders + 14, // 10: capiscio.v1.ParseServerIdentityRequest.jsonrpc_meta:type_name -> capiscio.v1.MCPJsonRpcMeta + 5, // 11: capiscio.v1.MCPService.EvaluateToolAccess:input_type -> capiscio.v1.EvaluateToolAccessRequest + 9, // 12: capiscio.v1.MCPService.VerifyServerIdentity:input_type -> capiscio.v1.VerifyServerIdentityRequest + 12, // 13: capiscio.v1.MCPService.ParseServerIdentity:input_type -> capiscio.v1.ParseServerIdentityRequest + 16, // 14: capiscio.v1.MCPService.Health:input_type -> capiscio.v1.MCPHealthRequest + 7, // 15: capiscio.v1.MCPService.EvaluateToolAccess:output_type -> capiscio.v1.EvaluateToolAccessResponse + 11, // 16: capiscio.v1.MCPService.VerifyServerIdentity:output_type -> capiscio.v1.VerifyServerIdentityResponse + 15, // 17: capiscio.v1.MCPService.ParseServerIdentity:output_type -> capiscio.v1.ParseServerIdentityResponse + 17, // 18: capiscio.v1.MCPService.Health:output_type -> capiscio.v1.MCPHealthResponse + 15, // [15:19] is the sub-list for method output_type + 11, // [11:15] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_capiscio_v1_mcp_proto_init() } @@ -1466,7 +1621,7 @@ func file_capiscio_v1_mcp_proto_init() { (*EvaluateToolAccessRequest_BadgeJws)(nil), (*EvaluateToolAccessRequest_ApiKey)(nil), } - file_capiscio_v1_mcp_proto_msgTypes[6].OneofWrappers = []any{ + file_capiscio_v1_mcp_proto_msgTypes[7].OneofWrappers = []any{ (*ParseServerIdentityRequest_HttpHeaders)(nil), (*ParseServerIdentityRequest_JsonrpcMeta)(nil), } @@ -1476,7 +1631,7 @@ func file_capiscio_v1_mcp_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_capiscio_v1_mcp_proto_rawDesc), len(file_capiscio_v1_mcp_proto_rawDesc)), NumEnums: 5, - NumMessages: 12, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/capiscio/v1/mcp.proto b/proto/capiscio/v1/mcp.proto index 1dd7f29..d59c6a9 100644 --- a/proto/capiscio/v1/mcp.proto +++ b/proto/capiscio/v1/mcp.proto @@ -60,6 +60,18 @@ message EvaluateToolAccessRequest { // Optional policy configuration string policy_version = 6; EvaluateConfig config = 7; + + // RFC-005: PDP integration context (badge-only mode: all empty/zero) + string enforcement_mode = 8; // EM-OBSERVE, EM-GUARD, EM-DELEGATE, EM-STRICT + // Field 9 reserved for future use between logical groups + reserved 9; + + // RFC-008: Authority Envelope context (future, all empty for now) + string capability_class = 10; // reserved for envelope + string envelope_id = 11; // reserved for envelope + int32 delegation_depth = 12; // reserved for envelope + string constraints_json = 13; // reserved for envelope + string parent_constraints_json = 14; // reserved for envelope } // Configuration for tool access evaluation @@ -103,6 +115,18 @@ message EvaluateToolAccessResponse { // Timestamp of evaluation google.protobuf.Timestamp timestamp = 10; + + // RFC-005: Policy decision context + string policy_decision_id = 11; // from PDP response + string policy_decision = 12; // ALLOW, DENY, or ALLOW_OBSERVE + string enforcement_mode = 13; // mode used for this evaluation + repeated MCPObligation obligations = 14; // obligations from PDP +} + +// Obligation returned by PDP (RFC-005 §7.1) +message MCPObligation { + string type = 1; + string params_json = 2; // opaque JSON } // Access decision enum From 0bb3fd39020290fe8c306d633f8777cc2aa21ccd Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 20 Mar 2026 11:21:42 -0400 Subject: [PATCH 2/4] refactor(gateway): extract pep methods to reduce cyclomatic complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split NewPolicyMiddleware (complexity 25) into pep struct with methods: - serveHTTP (5), evaluatePolicy (7), handleCachedDecision (7), handleBreakGlass (3), handlePDPUnavailable (2), handlePDPDeny (3), enforceObligations (4), buildPIPRequest (2) All functions now well under gocyclo threshold of 15. No behavioral changes — all 33 tests pass unchanged. --- pkg/gateway/middleware.go | 430 ++++++++++++++++++++++---------------- 1 file changed, 250 insertions(+), 180 deletions(-) diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index d17ec1e..203331e 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -70,209 +70,279 @@ type PolicyEvent struct { // Implementations should be non-blocking. type PolicyEventCallback func(event PolicyEvent, req *pip.DecisionRequest) +// pep is the internal Policy Enforcement Point handler. +type pep struct { + verifier *badge.Verifier + config PEPConfig + logger *slog.Logger + bgValidator *pip.BreakGlassValidator + callbacks []PolicyEventCallback + next http.Handler +} + // NewPolicyMiddleware creates a full PEP middleware (RFC-005). // When PEPConfig.PDPClient is nil, operates in badge-only mode (identical to NewAuthMiddleware). func NewPolicyMiddleware(verifier *badge.Verifier, config PEPConfig, next http.Handler, callbacks ...PolicyEventCallback) http.Handler { - logger := config.Logger - if logger == nil { - logger = slog.Default() + p := &pep{ + verifier: verifier, + config: config, + next: next, + callbacks: callbacks, + logger: config.Logger, + } + if p.logger == nil { + p.logger = slog.Default() } - - var bgValidator *pip.BreakGlassValidator if config.BreakGlassKey != nil { - bgValidator = pip.NewBreakGlassValidator(config.BreakGlassKey) + p.bgValidator = pip.NewBreakGlassValidator(config.BreakGlassKey) } + return http.HandlerFunc(p.serveHTTP) +} - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // --- 1. Extract and verify badge (authentication) --- - token := ExtractBadge(r) - if token == "" { - http.Error(w, "Missing Trust Badge", http.StatusUnauthorized) - return - } +// serveHTTP implements the PEP request flow: authenticate → break-glass → cache → PDP → enforce. +func (p *pep) serveHTTP(w http.ResponseWriter, r *http.Request) { + // --- 1. Extract and verify badge (authentication) --- + token := ExtractBadge(r) + if token == "" { + http.Error(w, "Missing Trust Badge", http.StatusUnauthorized) + return + } - claims, err := verifier.Verify(r.Context(), token) - if err != nil { - logger.WarnContext(r.Context(), "badge verification failed", slog.String("error", err.Error())) - http.Error(w, "Invalid Trust Badge", http.StatusUnauthorized) - return - } + claims, err := p.verifier.Verify(r.Context(), token) + if err != nil { + p.logger.WarnContext(r.Context(), "badge verification failed", slog.String("error", err.Error())) + http.Error(w, "Invalid Trust Badge", http.StatusUnauthorized) + return + } - // Forward verified identity to upstream - r.Header.Set("X-Capiscio-Subject", claims.Subject) - r.Header.Set("X-Capiscio-Issuer", claims.Issuer) + // Forward verified identity to upstream + r.Header.Set("X-Capiscio-Subject", claims.Subject) + r.Header.Set("X-Capiscio-Issuer", claims.Issuer) - // If no PDP configured, operate in badge-only mode - if config.PDPClient == nil { - next.ServeHTTP(w, r) - return - } + // If no PDP configured, operate in badge-only mode + if p.config.PDPClient == nil { + p.next.ServeHTTP(w, r) + return + } - // --- 2. Resolve txn_id (RFC-004 header or generate UUID v7) --- - txnID := r.Header.Get(pip.TxnIDHeader) - if txnID == "" { - txnID = uuid.Must(uuid.NewV7()).String() - } - r.Header.Set(pip.TxnIDHeader, txnID) - - // --- 3. Build PIP request --- - now := time.Now().UTC() - nowStr := now.Format(time.RFC3339) - pipReq := &pip.DecisionRequest{ - PIPVersion: pip.PIPVersion, - Subject: pip.SubjectAttributes{ - DID: claims.Subject, - BadgeJTI: claims.JTI, - IAL: claims.IAL, - TrustLevel: claims.TrustLevel(), - }, - Action: pip.ActionAttributes{ - Operation: r.Method + " " + r.URL.Path, - }, - Resource: pip.ResourceAttributes{ - Identifier: r.URL.Path, - }, - Context: pip.ContextAttributes{ - TxnID: txnID, - EnforcementMode: config.EnforcementMode.String(), - }, - Environment: pip.EnvironmentAttrs{ - PEPID: strPtr(config.PEPID), - Workspace: strPtr(config.Workspace), - Time: &nowStr, - }, - } + // --- 2-3. Build PIP request --- + pipReq := p.buildPIPRequest(r, claims) - event := PolicyEvent{} - - // --- 4. Check break-glass override --- - if bgValidator != nil { - if bgToken := extractBreakGlass(r, bgValidator); bgToken != nil { - logger.WarnContext(r.Context(), "break-glass override active", - slog.String(pip.TelemetryOverrideJTI, bgToken.JTI), - slog.String("operator", bgToken.SUB), - slog.String("reason", bgToken.Reason)) - - event.Decision = pip.DecisionAllow - event.DecisionID = "breakglass:" + bgToken.JTI - event.Override = true - event.OverrideJTI = bgToken.JTI - emitPolicyEvent(callbacks, event, pipReq) - next.ServeHTTP(w, r) - return - } - } + // --- 4. Check break-glass override --- + if p.handleBreakGlass(w, r, pipReq) { + return + } + + // --- 5-9. Cache → PDP → enforce → obligations --- + p.evaluatePolicy(w, r, claims, pipReq) +} + +// buildPIPRequest constructs the PIP decision request from the HTTP request and badge claims. +func (p *pep) buildPIPRequest(r *http.Request, claims *badge.Claims) *pip.DecisionRequest { + txnID := r.Header.Get(pip.TxnIDHeader) + if txnID == "" { + txnID = uuid.Must(uuid.NewV7()).String() + } + r.Header.Set(pip.TxnIDHeader, txnID) + + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + return &pip.DecisionRequest{ + PIPVersion: pip.PIPVersion, + Subject: pip.SubjectAttributes{ + DID: claims.Subject, + BadgeJTI: claims.JTI, + IAL: claims.IAL, + TrustLevel: claims.TrustLevel(), + }, + Action: pip.ActionAttributes{ + Operation: r.Method + " " + r.URL.Path, + }, + Resource: pip.ResourceAttributes{ + Identifier: r.URL.Path, + }, + Context: pip.ContextAttributes{ + TxnID: txnID, + EnforcementMode: p.config.EnforcementMode.String(), + }, + Environment: pip.EnvironmentAttrs{ + PEPID: strPtr(p.config.PEPID), + Workspace: strPtr(p.config.Workspace), + Time: &nowStr, + }, + } +} + +// handleBreakGlass checks for a valid break-glass override token. +// Returns true if the request was handled (break-glass token was valid). +func (p *pep) handleBreakGlass(w http.ResponseWriter, r *http.Request, pipReq *pip.DecisionRequest) bool { + if p.bgValidator == nil { + return false + } + + bgToken := extractBreakGlass(r, p.bgValidator) + if bgToken == nil { + return false + } - // --- 5. Check cache --- - cacheKey := pip.CacheKeyComponents(claims.Subject, claims.JTI, pipReq.Action.Operation, pipReq.Resource.Identifier) - if config.DecisionCache != nil { - if cached, ok := config.DecisionCache.Get(cacheKey); ok { - event.Decision = cached.Decision - event.DecisionID = cached.DecisionID - event.CacheHit = true - event.Obligations = obligationTypes(cached.Obligations) - - if cached.Decision == pip.DecisionDeny { - emitPolicyEvent(callbacks, event, pipReq) - http.Error(w, "Access denied by policy", http.StatusForbidden) - return - } - - // Handle obligations from cached response - if config.ObligationReg != nil && len(cached.Obligations) > 0 { - oblResult := config.ObligationReg.Enforce(r.Context(), config.EnforcementMode, cached.Obligations) - if !oblResult.Proceed { - event.Decision = pip.DecisionDeny - emitPolicyEvent(callbacks, event, pipReq) - http.Error(w, "Access denied: obligation enforcement failed", http.StatusForbidden) - return - } - } - - emitPolicyEvent(callbacks, event, pipReq) - next.ServeHTTP(w, r) - return - } + p.logger.WarnContext(r.Context(), "break-glass override active", + slog.String(pip.TelemetryOverrideJTI, bgToken.JTI), + slog.String("operator", bgToken.SUB), + slog.String("reason", bgToken.Reason)) + + emitPolicyEvent(p.callbacks, PolicyEvent{ + Decision: pip.DecisionAllow, + DecisionID: "breakglass:" + bgToken.JTI, + Override: true, + OverrideJTI: bgToken.JTI, + }, pipReq) + p.next.ServeHTTP(w, r) + return true +} + +// evaluatePolicy handles cache lookup, PDP query, decision enforcement, and obligations. +func (p *pep) evaluatePolicy(w http.ResponseWriter, r *http.Request, claims *badge.Claims, pipReq *pip.DecisionRequest) { + cacheKey := pip.CacheKeyComponents(claims.Subject, claims.JTI, pipReq.Action.Operation, pipReq.Resource.Identifier) + event := PolicyEvent{} + + // --- 5. Check cache --- + if p.handleCachedDecision(w, r, cacheKey, &event, pipReq) { + return + } + + // --- 6. Query PDP --- + start := time.Now() + resp, pdpErr := p.config.PDPClient.Evaluate(r.Context(), pipReq) + event.PDPLatencyMs = time.Since(start).Milliseconds() + + if pdpErr != nil { + p.logger.ErrorContext(r.Context(), "PDP unavailable", + slog.String(pip.TelemetryErrorCode, pip.ErrorCodePDPUnavailable), + slog.String("error", pdpErr.Error()), + slog.String("enforcement_mode", p.config.EnforcementMode.String())) + p.handlePDPUnavailable(w, r, &event, pipReq) + return + } + + event.Decision = resp.Decision + event.DecisionID = resp.DecisionID + event.Obligations = obligationTypes(resp.Obligations) + + // --- 7. Cache the response --- + if p.config.DecisionCache != nil { + maxTTL := time.Until(time.Unix(claims.Expiry, 0)) + if maxTTL > 0 { + p.config.DecisionCache.Put(cacheKey, resp, maxTTL) } + } + + // --- 8. Enforce decision --- + if resp.Decision == pip.DecisionDeny { + p.handlePDPDeny(w, r, resp, &event, pipReq) + return + } + + // --- 9. Handle obligations --- + if p.enforceObligations(w, r, resp.Obligations, &event, pipReq) { + return + } + + emitPolicyEvent(p.callbacks, event, pipReq) + p.next.ServeHTTP(w, r) +} + +// handleCachedDecision serves a cached PDP decision if available. +// Returns true if the request was handled from cache. +func (p *pep) handleCachedDecision(w http.ResponseWriter, r *http.Request, cacheKey string, event *PolicyEvent, pipReq *pip.DecisionRequest) bool { + if p.config.DecisionCache == nil { + return false + } + + cached, ok := p.config.DecisionCache.Get(cacheKey) + if !ok { + return false + } - // --- 6. Query PDP --- - start := time.Now() - resp, pdpErr := config.PDPClient.Evaluate(r.Context(), pipReq) - event.PDPLatencyMs = time.Since(start).Milliseconds() - - if pdpErr != nil { - // PDP unavailable — handle per enforcement mode (RFC-005 §7.4) - event.ErrorCode = pip.ErrorCodePDPUnavailable - logger.ErrorContext(r.Context(), "PDP unavailable", - slog.String(pip.TelemetryErrorCode, pip.ErrorCodePDPUnavailable), - slog.String("error", pdpErr.Error()), - slog.String("enforcement_mode", config.EnforcementMode.String())) - - if config.EnforcementMode == pip.EMObserve { - event.Decision = pip.DecisionObserve - event.DecisionID = "pdp-unavailable" - emitPolicyEvent(callbacks, event, pipReq) - next.ServeHTTP(w, r) - return - } - // EM-GUARD, EM-DELEGATE, EM-STRICT: fail-closed + event.Decision = cached.Decision + event.DecisionID = cached.DecisionID + event.CacheHit = true + event.Obligations = obligationTypes(cached.Obligations) + + if cached.Decision == pip.DecisionDeny { + emitPolicyEvent(p.callbacks, *event, pipReq) + http.Error(w, "Access denied by policy", http.StatusForbidden) + return true + } + + if p.config.ObligationReg != nil && len(cached.Obligations) > 0 { + oblResult := p.config.ObligationReg.Enforce(r.Context(), p.config.EnforcementMode, cached.Obligations) + if !oblResult.Proceed { event.Decision = pip.DecisionDeny - event.DecisionID = "pdp-unavailable" - emitPolicyEvent(callbacks, event, pipReq) - http.Error(w, "Access denied: policy service unavailable", http.StatusForbidden) - return + emitPolicyEvent(p.callbacks, *event, pipReq) + http.Error(w, "Access denied: obligation enforcement failed", http.StatusForbidden) + return true } + } - event.Decision = resp.Decision - event.DecisionID = resp.DecisionID - event.Obligations = obligationTypes(resp.Obligations) + emitPolicyEvent(p.callbacks, *event, pipReq) + p.next.ServeHTTP(w, r) + return true +} - // --- 7. Cache the response --- - if config.DecisionCache != nil { - maxTTL := time.Until(time.Unix(claims.Expiry, 0)) - if maxTTL > 0 { - config.DecisionCache.Put(cacheKey, resp, maxTTL) - } - } +// handlePDPUnavailable handles PDP unreachability per enforcement mode (RFC-005 §7.4). +func (p *pep) handlePDPUnavailable(w http.ResponseWriter, r *http.Request, event *PolicyEvent, pipReq *pip.DecisionRequest) { + event.ErrorCode = pip.ErrorCodePDPUnavailable - // --- 8. Enforce decision --- - if resp.Decision == pip.DecisionDeny { - switch config.EnforcementMode { - case pip.EMObserve: - // Log only, allow through - logger.InfoContext(r.Context(), "PDP DENY in EM-OBSERVE (allowing)", - slog.String(pip.TelemetryDecisionID, resp.DecisionID)) - event.Decision = pip.DecisionObserve - emitPolicyEvent(callbacks, event, pipReq) - next.ServeHTTP(w, r) - return - default: - // EM-GUARD, EM-DELEGATE, EM-STRICT: block - reason := "Access denied by policy" - if resp.Reason != "" { - reason = resp.Reason - } - emitPolicyEvent(callbacks, event, pipReq) - http.Error(w, reason, http.StatusForbidden) - return - } - } + if p.config.EnforcementMode == pip.EMObserve { + event.Decision = pip.DecisionObserve + event.DecisionID = "pdp-unavailable" + emitPolicyEvent(p.callbacks, *event, pipReq) + p.next.ServeHTTP(w, r) + return + } - // --- 9. Handle obligations --- - if config.ObligationReg != nil && len(resp.Obligations) > 0 { - oblResult := config.ObligationReg.Enforce(r.Context(), config.EnforcementMode, resp.Obligations) - if !oblResult.Proceed { - event.Decision = pip.DecisionDeny - emitPolicyEvent(callbacks, event, pipReq) - http.Error(w, "Access denied: obligation enforcement failed", http.StatusForbidden) - return - } + // EM-GUARD, EM-DELEGATE, EM-STRICT: fail-closed + event.Decision = pip.DecisionDeny + event.DecisionID = "pdp-unavailable" + emitPolicyEvent(p.callbacks, *event, pipReq) + http.Error(w, "Access denied: policy service unavailable", http.StatusForbidden) +} + +// handlePDPDeny handles a DENY decision from the PDP per enforcement mode. +func (p *pep) handlePDPDeny(w http.ResponseWriter, r *http.Request, resp *pip.DecisionResponse, event *PolicyEvent, pipReq *pip.DecisionRequest) { + switch p.config.EnforcementMode { + case pip.EMObserve: + p.logger.InfoContext(r.Context(), "PDP DENY in EM-OBSERVE (allowing)", + slog.String(pip.TelemetryDecisionID, resp.DecisionID)) + event.Decision = pip.DecisionObserve + emitPolicyEvent(p.callbacks, *event, pipReq) + p.next.ServeHTTP(w, r) + default: + reason := "Access denied by policy" + if resp.Reason != "" { + reason = resp.Reason } + emitPolicyEvent(p.callbacks, *event, pipReq) + http.Error(w, reason, http.StatusForbidden) + } +} - // --- 10. Emit telemetry and forward --- - emitPolicyEvent(callbacks, event, pipReq) - next.ServeHTTP(w, r) - }) +// enforceObligations attempts to enforce obligations from the PDP response. +// Returns true if the request was denied due to obligation failure. +func (p *pep) enforceObligations(w http.ResponseWriter, r *http.Request, obligations []pip.Obligation, event *PolicyEvent, pipReq *pip.DecisionRequest) bool { + if p.config.ObligationReg == nil || len(obligations) == 0 { + return false + } + + oblResult := p.config.ObligationReg.Enforce(r.Context(), p.config.EnforcementMode, obligations) + if !oblResult.Proceed { + event.Decision = pip.DecisionDeny + emitPolicyEvent(p.callbacks, *event, pipReq) + http.Error(w, "Access denied: obligation enforcement failed", http.StatusForbidden) + return true + } + + return false } // ExtractBadge retrieves the badge from headers. From fcb23e6fdec388e6047efd692f5091c92e476bbe Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 20 Mar 2026 11:28:32 -0400 Subject: [PATCH 3/4] fix: address PR #41 review comments - Validate PDP response (Decision + DecisionID) in gateway and guard - Guard nil logger safety in WithGuardLogger - Replace uuid.Must(uuid.NewV7()) with error-handled fallback - Restrict ParseBreakGlassJWS to EdDSA only - Add panic recovery to emitPolicyEvent callbacks --- pkg/gateway/middleware.go | 27 +++++++++++++++++++++++++-- pkg/mcp/guard.go | 36 ++++++++++++++++++++++++++++++++++-- pkg/pip/breakglass.go | 2 +- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 203331e..9b4d2c9 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -141,7 +141,12 @@ func (p *pep) serveHTTP(w http.ResponseWriter, r *http.Request) { func (p *pep) buildPIPRequest(r *http.Request, claims *badge.Claims) *pip.DecisionRequest { txnID := r.Header.Get(pip.TxnIDHeader) if txnID == "" { - txnID = uuid.Must(uuid.NewV7()).String() + if u, err := uuid.NewV7(); err != nil { + p.logger.ErrorContext(r.Context(), "failed to generate UUID v7 for txn_id", slog.String("error", err.Error())) + txnID = uuid.New().String() + } else { + txnID = u.String() + } } r.Header.Set(pip.TxnIDHeader, txnID) @@ -224,6 +229,16 @@ func (p *pep) evaluatePolicy(w http.ResponseWriter, r *http.Request, claims *bad return } + // Validate PDP response: Decision must be ALLOW or DENY, DecisionID must be non-empty. + // A non-compliant response is treated as PDP unavailability (fail-closed except EM-OBSERVE). + if !pip.ValidDecision(resp.Decision) || resp.DecisionID == "" { + p.logger.ErrorContext(r.Context(), "PDP returned non-compliant response", + slog.String("decision", resp.Decision), + slog.String("decision_id", resp.DecisionID)) + p.handlePDPUnavailable(w, r, &event, pipReq) + return + } + event.Decision = resp.Decision event.DecisionID = resp.DecisionID event.Obligations = obligationTypes(resp.Obligations) @@ -405,6 +420,14 @@ func obligationTypes(obs []pip.Obligation) []string { func emitPolicyEvent(callbacks []PolicyEventCallback, event PolicyEvent, req *pip.DecisionRequest) { for _, cb := range callbacks { - cb(event, req) + cb := cb + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("policy event callback panicked", "panic", r) + } + }() + cb(event, req) + }() } } diff --git a/pkg/mcp/guard.go b/pkg/mcp/guard.go index 02d094b..f17d120 100644 --- a/pkg/mcp/guard.go +++ b/pkg/mcp/guard.go @@ -44,8 +44,15 @@ func WithObligationRegistry(reg *pip.ObligationRegistry) GuardOption { } // WithGuardLogger sets the logger for the guard. +// A nil logger is treated as slog.Default(). func WithGuardLogger(logger *slog.Logger) GuardOption { - return func(g *Guard) { g.logger = logger } + return func(g *Guard) { + if logger == nil { + g.logger = slog.Default() + return + } + g.logger = logger + } } // EvidenceStore is the interface for storing evidence records @@ -175,7 +182,13 @@ func (g *Guard) evaluateWithPDP( ) { now := time.Now().UTC() nowStr := now.Format(time.RFC3339) - txnID := uuid.Must(uuid.NewV7()).String() + var txnID string + if u, err := uuid.NewV7(); err != nil { + g.logger.ErrorContext(ctx, "failed to generate UUID v7 for txn_id", slog.String("error", err.Error())) + txnID = uuid.New().String() + } else { + txnID = u.String() + } pipReq := &pip.DecisionRequest{ PIPVersion: pip.PIPVersion, @@ -223,6 +236,25 @@ func (g *Guard) evaluateWithPDP( return } + // Validate PDP response: Decision must be ALLOW or DENY, DecisionID must be non-empty. + if !pip.ValidDecision(resp.Decision) || resp.DecisionID == "" { + g.logger.ErrorContext(ctx, "PDP returned non-compliant response", + slog.String("decision", resp.Decision), + slog.String("decision_id", resp.DecisionID)) + + if g.emMode == pip.EMObserve { + result.PolicyDecision = pip.DecisionObserve + result.PolicyDecisionID = "pdp-invalid-response" + return + } + result.Decision = DecisionDeny + result.DenyReason = DenyReasonPolicyDenied + result.DenyDetail = "policy service returned non-compliant response" + result.PolicyDecision = pip.DecisionDeny + result.PolicyDecisionID = "pdp-invalid-response" + return + } + result.PolicyDecisionID = resp.DecisionID result.PolicyDecision = resp.Decision diff --git a/pkg/pip/breakglass.go b/pkg/pip/breakglass.go index bd3d360..8c96af6 100644 --- a/pkg/pip/breakglass.go +++ b/pkg/pip/breakglass.go @@ -129,7 +129,7 @@ func (v *BreakGlassValidator) PublicKey() crypto.PublicKey { // ParseBreakGlassJWS verifies a compact JWS break-glass token and extracts claims. // The publicKey MUST be the dedicated break-glass key, not the CA badge-signing key. func ParseBreakGlassJWS(compact string, publicKey crypto.PublicKey) (*BreakGlassToken, error) { - jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.EdDSA, jose.ES256}) + jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.EdDSA}) if err != nil { return nil, fmt.Errorf("breakglass: parse JWS: %w", err) } From 2ccb4524b58122aa8910ffc11d1910fbe1d11582 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 20 Mar 2026 11:38:56 -0400 Subject: [PATCH 4/4] fix(gateway): cached DENY respects enforcement mode and PDP reason - Cached DENY in EM-OBSERVE now allows through with ALLOW_OBSERVE - Use cached PDP reason in 403 response when available - Fix PolicyEventCallback comment: synchronous, not non-blocking --- pkg/gateway/middleware.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 9b4d2c9..7d16ec4 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -66,8 +66,8 @@ type PolicyEvent struct { ErrorCode string } -// PolicyEventCallback is invoked after each policy enforcement with the event data. -// Implementations should be non-blocking. +// PolicyEventCallback is invoked synchronously after each policy enforcement with the event data. +// Implementations MUST return quickly and avoid long-running or blocking operations. type PolicyEventCallback func(event PolicyEvent, req *pip.DecisionRequest) // pep is the internal Policy Enforcement Point handler. @@ -284,8 +284,20 @@ func (p *pep) handleCachedDecision(w http.ResponseWriter, r *http.Request, cache event.Obligations = obligationTypes(cached.Obligations) if cached.Decision == pip.DecisionDeny { + if p.config.EnforcementMode == pip.EMObserve { + p.logger.InfoContext(r.Context(), "cached PDP DENY in EM-OBSERVE (allowing)", + slog.String(pip.TelemetryDecisionID, cached.DecisionID)) + event.Decision = pip.DecisionObserve + emitPolicyEvent(p.callbacks, *event, pipReq) + p.next.ServeHTTP(w, r) + return true + } + reason := cached.Reason + if reason == "" { + reason = "Access denied by policy" + } emitPolicyEvent(p.callbacks, *event, pipReq) - http.Error(w, "Access denied by policy", http.StatusForbidden) + http.Error(w, reason, http.StatusForbidden) return true }