diff --git a/internal/rpc/mcp_service.go b/internal/rpc/mcp_service.go index df9d27f..fd9c8b5 100644 --- a/internal/rpc/mcp_service.go +++ b/internal/rpc/mcp_service.go @@ -11,14 +11,16 @@ import ( "github.com/capiscio/capiscio-core/v2/pkg/badge" "github.com/capiscio/capiscio-core/v2/pkg/mcp" + "github.com/capiscio/capiscio-core/v2/pkg/pip" "github.com/capiscio/capiscio-core/v2/pkg/registry" pb "github.com/capiscio/capiscio-core/v2/pkg/rpc/gen/capiscio/v1" ) -// MCPService implements the MCPServiceServer interface for RFC-006 and RFC-007. +// MCPService implements the MCPServiceServer interface for RFC-005, RFC-006, and RFC-007. type MCPService struct { pb.UnimplementedMCPServiceServer - service *mcp.Service + service *mcp.Service + decisionCache pip.DecisionCache } // MCPServiceConfig configures the MCP service @@ -114,7 +116,8 @@ func NewMCPServiceWithConfig(cfg MCPServiceConfig) (*MCPService, error) { } return &MCPService{ - service: mcp.NewService(deps), + service: mcp.NewService(deps), + decisionCache: pip.NewInMemoryCache(), }, nil } diff --git a/internal/rpc/policy_decision.go b/internal/rpc/policy_decision.go new file mode 100644 index 0000000..1c6bc0d --- /dev/null +++ b/internal/rpc/policy_decision.go @@ -0,0 +1,435 @@ +// Package rpc provides the gRPC server implementation for CapiscIO SDK integration. +package rpc + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "log/slog" + "net" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/capiscio/capiscio-core/v2/pkg/pip" + pb "github.com/capiscio/capiscio-core/v2/pkg/rpc/gen/capiscio/v1" +) + +// EvaluatePolicyDecision implements RFC-005 centralized policy decision. +// +// Protocol boundary: +// - Go core owns: PDP query, decision cache, break-glass, enforcement mode, telemetry +// - SDK caller owns: obligation execution, response propagation, surface error handling +// +// This RPC NEVER returns a gRPC error for PDP unreachability. All outcomes +// are encoded in the response so SDKs don't need to distinguish transport +// errors from policy outcomes. +func (s *MCPService) EvaluatePolicyDecision( + ctx context.Context, + req *pb.PolicyDecisionRequest, +) (*pb.PolicyDecisionResponse, error) { + cfg := req.GetConfig() + if cfg == nil { + cfg = &pb.PolicyConfig{} + } + + // Validate enforcement mode upfront so badge-only mode gets the same + // config error behaviour as the PDP path. + modeStr := enforcementModeOrDefault(cfg.EnforcementMode) + em, err := pip.ParseEnforcementMode(modeStr) + if err != nil { + return nil, fmt.Errorf("invalid enforcement_mode %q: %w", cfg.EnforcementMode, err) + } + + // Badge-only mode: no PDP configured, pass through. + if cfg.PdpEndpoint == "" { + return &pb.PolicyDecisionResponse{ + Decision: pip.DecisionAllow, + DecisionId: "no-pdp-configured", + EnforcementMode: modeStr, + TxnId: generateTxnID(), + }, nil + } + + txnID := generateTxnID() + + pipReq := buildPIPRequest(req, txnID, em) + + action := req.GetAction() + subject := req.GetSubject() + + // Check break-glass override + if req.BreakglassToken != "" { + bgResp := s.handleBreakGlass(req.BreakglassToken, cfg, action.GetOperation(), txnID, em) + if bgResp != nil { + return bgResp, nil + } + // If nil, break-glass validation failed — proceed with normal PDP path + } + + // Check decision cache + cacheKey := pip.CacheKeyComponents( + subject.GetDid(), + subject.GetBadgeJti(), + action.GetOperation(), + req.GetResource().GetIdentifier(), + em.String(), + ) + if s.decisionCache != nil { + if cached, ok := s.decisionCache.Get(cacheKey); ok { + return s.buildCachedResponse(cached, em, txnID), nil + } + } + + // Query PDP + resp, latencyMs, pdpErr := s.queryPDP(ctx, cfg, pipReq) + if pdpErr != nil { + // Client-initiated cancellation should propagate as an RPC error, + // not a synthetic policy response. + if errors.Is(pdpErr, context.Canceled) { + return nil, pdpErr + } + return s.handlePDPUnavailable(pdpErr, em, txnID, latencyMs), nil + } + + // Cache the decision + if s.decisionCache != nil { + maxTTL := badgeExpTTL(subject.GetBadgeExp()) + s.decisionCache.Put(cacheKey, resp, maxTTL) + } + + // Build response based on decision and enforcement mode + return s.buildLiveResponse(resp, em, txnID, latencyMs), nil +} + +// queryPDP creates an HTTP PDP client, queries it, and validates the response. +// Returns the validated response and latency on success; returns an error on +// any failure (client cancellation, PDP unreachable, or non-compliant response). +func (s *MCPService) queryPDP( + ctx context.Context, + cfg *pb.PolicyConfig, + pipReq *pip.DecisionRequest, +) (*pip.DecisionResponse, int64, error) { + pdpTimeout := time.Duration(cfg.PdpTimeoutMs) * time.Millisecond + if pdpTimeout <= 0 { + pdpTimeout = pip.DefaultPDPTimeout + } + client := pip.NewHTTPPDPClient(cfg.PdpEndpoint, pdpTimeout, pip.WithPEPID(cfg.PepId)) + + if ctx.Err() != nil { + return nil, 0, ctx.Err() + } + + start := time.Now() + resp, err := client.Evaluate(ctx, pipReq) + latencyMs := time.Since(start).Milliseconds() + + if err != nil { + return nil, latencyMs, err + } + if !pip.ValidDecision(resp.Decision) || resp.DecisionID == "" { + return nil, latencyMs, &invalidPDPResponseError{decision: resp.Decision, decisionID: resp.DecisionID} + } + return resp, latencyMs, nil +} + +// handleBreakGlass validates a break-glass token and returns a response if valid. +// Returns nil if validation fails (caller should proceed with normal PDP path). +func (s *MCPService) handleBreakGlass( + token string, + cfg *pb.PolicyConfig, + operation string, + txnID string, + em pip.EnforcementMode, +) *pb.PolicyDecisionResponse { + if len(cfg.BreakglassPublicKey) == 0 { + slog.Warn("break-glass token provided but no public key configured") + return nil + } + + if len(cfg.BreakglassPublicKey) != ed25519.PublicKeySize { + slog.Error("break-glass public key has wrong size", + slog.Int("expected", ed25519.PublicKeySize), + slog.Int("got", len(cfg.BreakglassPublicKey)), + ) + return nil + } + pubKey := ed25519.PublicKey(cfg.BreakglassPublicKey) + + bgToken, err := pip.ParseBreakGlassJWS(token, pubKey) + if err != nil { + slog.Warn("break-glass token signature invalid", slog.String("error", err.Error())) + return nil + } + + validator := pip.NewBreakGlassValidator(pubKey) + if err := validator.ValidateToken(bgToken); err != nil { + slog.Warn("break-glass token claims invalid", slog.String("error", err.Error())) + return nil + } + + // Check scope — use "*" for method since gRPC doesn't have HTTP methods + if !validator.MatchesScope(bgToken, "*", operation) { + slog.Warn("break-glass token scope does not cover operation", + slog.String("operation", operation), + ) + return nil + } + + return &pb.PolicyDecisionResponse{ + Decision: pip.DecisionAllow, + DecisionId: fmt.Sprintf("breakglass:%s", bgToken.JTI), + Reason: bgToken.Reason, + EnforcementMode: em.String(), + BreakglassOverride: true, + BreakglassJti: bgToken.JTI, + TxnId: txnID, + } +} + +// handlePDPUnavailable returns a response when the PDP cannot be reached. +// In EM-OBSERVE: ALLOW_OBSERVE with error_code (not an RPC error). +// All other modes: DENY with error_code. +func (s *MCPService) handlePDPUnavailable( + pdpErr error, + em pip.EnforcementMode, + txnID string, + latencyMs int64, +) *pb.PolicyDecisionResponse { + // Log the raw error server-side for debugging; expose only the error_code + // and a stable reason to callers so internal network details don't leak. + slog.Warn("PDP query failed", + slog.String("txn_id", txnID), + slog.String("error", pdpErr.Error()), + ) + + errCode := classifyPDPError(pdpErr) + + if em == pip.EMObserve { + return &pb.PolicyDecisionResponse{ + Decision: pip.DecisionObserve, + DecisionId: "pdp-unavailable", + Reason: "policy service unavailable", + EnforcementMode: em.String(), + ErrorCode: errCode, + PdpLatencyMs: latencyMs, + TxnId: txnID, + } + } + + return &pb.PolicyDecisionResponse{ + Decision: pip.DecisionDeny, + DecisionId: "pdp-unavailable", + Reason: "policy service unavailable", + EnforcementMode: em.String(), + ErrorCode: errCode, + PdpLatencyMs: latencyMs, + TxnId: txnID, + } +} + +// buildCachedResponse converts a cached DecisionResponse to a proto response. +func (s *MCPService) buildCachedResponse( + cached *pip.DecisionResponse, + em pip.EnforcementMode, + txnID string, +) *pb.PolicyDecisionResponse { + resp := &pb.PolicyDecisionResponse{ + Decision: cached.Decision, + DecisionId: cached.DecisionID, + Reason: cached.Reason, + EnforcementMode: em.String(), + CacheHit: true, + TxnId: txnID, + } + + // For cached DENY in EM-OBSERVE: override to ALLOW_OBSERVE + if cached.Decision == pip.DecisionDeny && em == pip.EMObserve { + resp.Decision = pip.DecisionObserve + } + + // Return obligations from cached response + resp.Obligations = obligationsToProto(cached.Obligations) + + if cached.TTL != nil { + resp.Ttl = int32(*cached.TTL) + } + + return resp +} + +// buildLiveResponse converts a live PDP DecisionResponse to a proto response, +// applying enforcement mode logic. +func (s *MCPService) buildLiveResponse( + pdpResp *pip.DecisionResponse, + em pip.EnforcementMode, + txnID string, + latencyMs int64, +) *pb.PolicyDecisionResponse { + resp := &pb.PolicyDecisionResponse{ + DecisionId: pdpResp.DecisionID, + Reason: pdpResp.Reason, + EnforcementMode: em.String(), + PdpLatencyMs: latencyMs, + TxnId: txnID, + Obligations: obligationsToProto(pdpResp.Obligations), + } + + if pdpResp.TTL != nil { + resp.Ttl = int32(*pdpResp.TTL) + } + + if pdpResp.Decision == pip.DecisionDeny { + if em == pip.EMObserve { + // PDP said DENY but we're in observe mode — pass through as ALLOW_OBSERVE + resp.Decision = pip.DecisionObserve + } else { + resp.Decision = pip.DecisionDeny + } + return resp + } + + // ALLOW — obligations are returned to the caller for enforcement. + // Obligation execution is context-dependent (rate limiting needs the + // SDK's HTTP layer, logging needs the SDK's logger) so the Go core + // only returns them; the SDK decides how to handle each type per the + // enforcement mode it already knows. + resp.Decision = pip.DecisionAllow + + return resp +} + +// buildPIPRequest constructs a PIP DecisionRequest from the proto request fields. +func buildPIPRequest(req *pb.PolicyDecisionRequest, txnID string, em pip.EnforcementMode) *pip.DecisionRequest { + cfg := req.GetConfig() + subject := req.GetSubject() + action := req.GetAction() + resource := req.GetResource() + + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + pipReq := &pip.DecisionRequest{ + PIPVersion: pip.PIPVersion, + Subject: pip.SubjectAttributes{ + DID: subject.GetDid(), + BadgeJTI: subject.GetBadgeJti(), + IAL: subject.GetIal(), + TrustLevel: subject.GetTrustLevel(), + }, + Action: pip.ActionAttributes{ + Operation: action.GetOperation(), + }, + Resource: pip.ResourceAttributes{ + Identifier: resource.GetIdentifier(), + }, + Context: pip.ContextAttributes{ + TxnID: txnID, + EnforcementMode: em.String(), + }, + Environment: pip.EnvironmentAttrs{ + Time: &nowStr, + }, + } + + if action.GetCapabilityClass() != "" { + cc := action.GetCapabilityClass() + pipReq.Action.CapabilityClass = &cc + } + if cfg.GetPepId() != "" { + pepID := cfg.GetPepId() + pipReq.Environment.PEPID = &pepID + } + if cfg.GetWorkspace() != "" { + ws := cfg.GetWorkspace() + pipReq.Environment.Workspace = &ws + } + + return pipReq +} + +// obligationsToProto converts pip.Obligation slice to proto MCPObligation slice. +func obligationsToProto(obligations []pip.Obligation) []*pb.MCPObligation { + if len(obligations) == 0 { + return nil + } + result := make([]*pb.MCPObligation, len(obligations)) + for i, ob := range obligations { + result[i] = &pb.MCPObligation{ + Type: ob.Type, + ParamsJson: string(ob.Params), + } + } + return result +} + +// invalidPDPResponseError indicates the PDP returned a non-compliant response. +type invalidPDPResponseError struct { + decision string + decisionID string +} + +func (e *invalidPDPResponseError) Error() string { + return fmt.Sprintf("non-compliant PDP response: decision=%q decision_id=%q", e.decision, e.decisionID) +} + +// classifyPDPError maps a PDP error to a specific error_code string. +func classifyPDPError(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "pdp_timeout" + } + // net/http timeout errors (Client.Timeout) expose a Timeout() method + var urlErr *url.Error + if errors.As(err, &urlErr) && urlErr.Timeout() { + return "pdp_timeout" + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "pdp_timeout" + } + // Errors from our handler or from the PDP client about non-compliant responses + var invResp *invalidPDPResponseError + if errors.As(err, &invResp) { + return "pdp_invalid_response" + } + // The HTTPPDPClient validates decision/decision_id — those errors + // contain specific substrings we can match for classification. + errMsg := err.Error() + if strings.Contains(errMsg, "invalid decision") || strings.Contains(errMsg, "empty decision_id") || strings.Contains(errMsg, "unmarshal") { + return "pdp_invalid_response" + } + return "pdp_unavailable" +} + +// enforcementModeOrDefault returns the enforcement mode string or "EM-OBSERVE" if empty. +func enforcementModeOrDefault(s string) string { + if s == "" { + return "EM-OBSERVE" + } + return s +} + +// generateTxnID creates a UUID v7 transaction ID, falling back to v4. +func generateTxnID() string { + if id, err := uuid.NewV7(); err == nil { + return id.String() + } + return uuid.New().String() +} + +// badgeExpTTL computes a cache TTL bounded by the badge expiration. +// Returns 0 when badge_exp is missing/invalid — caller should skip caching +// because RFC-005 requires cache lifetime to be bounded by badge expiry. +func badgeExpTTL(badgeExp int64) time.Duration { + if badgeExp <= 0 { + return 0 + } + remaining := time.Until(time.Unix(badgeExp, 0)) + if remaining <= 0 { + return 0 + } + return remaining +} diff --git a/internal/rpc/policy_decision_test.go b/internal/rpc/policy_decision_test.go new file mode 100644 index 0000000..23d223d --- /dev/null +++ b/internal/rpc/policy_decision_test.go @@ -0,0 +1,652 @@ +package rpc + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "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/pip" + pb "github.com/capiscio/capiscio-core/v2/pkg/rpc/gen/capiscio/v1" +) + +// mockPDP sets up an httptest server returning a fixed PDP response. +func mockPDP(t *testing.T, decision, decisionID, reason string, ttl *int, obligations []pip.Obligation) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := pip.DecisionResponse{ + Decision: decision, + DecisionID: decisionID, + Reason: reason, + TTL: ttl, + Obligations: obligations, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) +} + +// mockPDPError sets up an httptest server that returns an error status. +func mockPDPError(t *testing.T, statusCode int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "pdp error", statusCode) + })) +} + +// mockPDPSlow sets up an httptest server that takes longer than timeout. +func mockPDPSlow(t *testing.T, delay time.Duration) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(delay) + resp := pip.DecisionResponse{Decision: "ALLOW", DecisionID: "slow-1"} + json.NewEncoder(w).Encode(resp) + })) +} + +func basicRequest(pdpEndpoint string) *pb.PolicyDecisionRequest { + return &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{ + Did: "did:web:agent.example.com", + BadgeJti: "badge-123", + Ial: "ial-2", + TrustLevel: "2", + BadgeExp: time.Now().Add(1 * time.Hour).Unix(), + }, + Action: &pb.PolicyAction{ + Operation: "read_file", + }, + Resource: &pb.PolicyResource{ + Identifier: "/data/report.csv", + }, + Config: &pb.PolicyConfig{ + PdpEndpoint: pdpEndpoint, + PdpTimeoutMs: 500, + EnforcementMode: "EM-GUARD", + PepId: "test-pep", + }, + } +} + +func newTestService(t *testing.T) *MCPService { + t.Helper() + svc, err := NewMCPServiceWithConfig(MCPServiceConfig{}) + require.NoError(t, err, "newTestService: failed to create MCPService") + return svc +} + +func TestEvaluatePolicyDecision_NoPDP(t *testing.T) { + svc := newTestService(t) + + req := &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{Did: "did:web:test"}, + Action: &pb.PolicyAction{Operation: "read_file"}, + Resource: &pb.PolicyResource{Identifier: "/test"}, + Config: &pb.PolicyConfig{}, // No PDP endpoint + } + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp.Decision) + assert.Equal(t, "no-pdp-configured", resp.DecisionId) + assert.Equal(t, "EM-OBSERVE", resp.EnforcementMode) + assert.NotEmpty(t, resp.TxnId) +} + +func TestEvaluatePolicyDecision_PDPAllow(t *testing.T) { + pdp := mockPDP(t, "ALLOW", "dec-allow-1", "", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp.Decision) + assert.Equal(t, "dec-allow-1", resp.DecisionId) + assert.Equal(t, "EM-GUARD", resp.EnforcementMode) + assert.False(t, resp.CacheHit) + assert.False(t, resp.BreakglassOverride) + assert.NotEmpty(t, resp.TxnId) + assert.GreaterOrEqual(t, resp.PdpLatencyMs, int64(0)) +} + +func TestEvaluatePolicyDecision_PDPDeny_Guard(t *testing.T) { + pdp := mockPDP(t, "DENY", "dec-deny-1", "insufficient trust", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = "EM-GUARD" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionDeny, resp.Decision) + assert.Equal(t, "dec-deny-1", resp.DecisionId) + assert.Equal(t, "insufficient trust", resp.Reason) +} + +func TestEvaluatePolicyDecision_PDPDeny_Observe(t *testing.T) { + pdp := mockPDP(t, "DENY", "dec-deny-2", "denied by policy", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = "EM-OBSERVE" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionObserve, resp.Decision, "EM-OBSERVE should convert DENY to ALLOW_OBSERVE") + assert.Equal(t, "dec-deny-2", resp.DecisionId) +} + +func TestEvaluatePolicyDecision_PDPUnavailable_FailClosed(t *testing.T) { + pdp := mockPDPError(t, http.StatusInternalServerError) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = "EM-STRICT" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err, "PDP unavailability must not produce an RPC error") + assert.Equal(t, pip.DecisionDeny, resp.Decision) + assert.Equal(t, "pdp-unavailable", resp.DecisionId) + assert.Equal(t, "pdp_unavailable", resp.ErrorCode) +} + +func TestEvaluatePolicyDecision_PDPUnavailable_Observe(t *testing.T) { + pdp := mockPDPError(t, http.StatusInternalServerError) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = "EM-OBSERVE" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err, "PDP unavailability must not produce an RPC error") + assert.Equal(t, pip.DecisionObserve, resp.Decision, "EM-OBSERVE + PDP unavailable → ALLOW_OBSERVE") + assert.Equal(t, "pdp_unavailable", resp.ErrorCode) + assert.NotEmpty(t, resp.Reason) +} + +func TestEvaluatePolicyDecision_PDPTimeout(t *testing.T) { + pdp := mockPDPSlow(t, 2*time.Second) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.PdpTimeoutMs = 50 // 50ms timeout, PDP takes 2s + req.Config.EnforcementMode = "EM-DELEGATE" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err, "PDP timeout must not produce an RPC error") + assert.Equal(t, pip.DecisionDeny, resp.Decision) + assert.Equal(t, "pdp_timeout", resp.ErrorCode) +} + +func TestEvaluatePolicyDecision_InvalidEnforcementMode(t *testing.T) { + svc := newTestService(t) + req := basicRequest("http://localhost:9999") + req.Config.EnforcementMode = "INVALID-MODE" + + _, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.Error(t, err, "Invalid enforcement mode should return an RPC error") + assert.Contains(t, err.Error(), "invalid enforcement_mode") +} + +func TestEvaluatePolicyDecision_DefaultEnforcementMode(t *testing.T) { + svc := newTestService(t) + + req := &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{Did: "did:web:test"}, + Action: &pb.PolicyAction{Operation: "test"}, + Resource: &pb.PolicyResource{Identifier: "/test"}, + Config: &pb.PolicyConfig{}, // No enforcement mode, no PDP + } + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, "EM-OBSERVE", resp.EnforcementMode) +} + +func TestEvaluatePolicyDecision_NilConfig(t *testing.T) { + svc := newTestService(t) + + req := &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{Did: "did:web:test"}, + Action: &pb.PolicyAction{Operation: "test"}, + Resource: &pb.PolicyResource{Identifier: "/test"}, + // No config at all + } + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp.Decision) + assert.Equal(t, "no-pdp-configured", resp.DecisionId) +} + +func TestEvaluatePolicyDecision_WithObligations(t *testing.T) { + obligations := []pip.Obligation{ + {Type: "rate_limit", Params: json.RawMessage(`{"max_rps": 10}`)}, + {Type: "audit_log", Params: json.RawMessage(`{"level": "info"}`)}, + } + pdp := mockPDP(t, "ALLOW", "dec-obl-1", "", nil, obligations) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp.Decision) + require.Len(t, resp.Obligations, 2) + assert.Equal(t, "rate_limit", resp.Obligations[0].Type) + assert.JSONEq(t, `{"max_rps": 10}`, resp.Obligations[0].ParamsJson) + assert.Equal(t, "audit_log", resp.Obligations[1].Type) +} + +func TestEvaluatePolicyDecision_CacheHit(t *testing.T) { + callCount := 0 + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + resp := pip.DecisionResponse{ + Decision: "ALLOW", + DecisionID: fmt.Sprintf("dec-cache-%d", callCount), + } + json.NewEncoder(w).Encode(resp) + })) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + + // First call: cache miss + resp1, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp1.Decision) + assert.False(t, resp1.CacheHit) + assert.Equal(t, 1, callCount) + + // Second call: cache hit (same subject/action/resource) + resp2, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp2.Decision) + assert.True(t, resp2.CacheHit) + assert.Equal(t, 1, callCount, "PDP should not be called again on cache hit") +} + +func TestEvaluatePolicyDecision_CachedDeny_Observe(t *testing.T) { + pdp := mockPDP(t, "DENY", "dec-deny-cached", "denied", nil, nil) + defer pdp.Close() + + // Create service with DENY caching enabled + svc := newTestService(t) + svc.decisionCache = pip.NewInMemoryCache(pip.WithCacheDeny(true)) + + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = "EM-OBSERVE" + + // First call — caches the DENY under EM-OBSERVE key + resp1, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionObserve, resp1.Decision) + + // Second call with same EM-OBSERVE — cached DENY returned as ALLOW_OBSERVE + resp2, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionObserve, resp2.Decision) + assert.True(t, resp2.CacheHit) +} + +func TestEvaluatePolicyDecision_InvalidPDPResponse(t *testing.T) { + // PDP returns invalid decision + pdp := mockPDP(t, "MAYBE", "dec-invalid", "", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = "EM-DELEGATE" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err, "Invalid PDP response must not produce an RPC error") + assert.Equal(t, pip.DecisionDeny, resp.Decision) + assert.Equal(t, "pdp_invalid_response", resp.ErrorCode) +} + +func TestEvaluatePolicyDecision_EmptyDecisionID(t *testing.T) { + // PDP returns empty decision_id (non-compliant) + pdp := mockPDP(t, "ALLOW", "", "", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionDeny, resp.Decision) + assert.Equal(t, "pdp_invalid_response", resp.ErrorCode) +} + +func TestEvaluatePolicyDecision_WithTTL(t *testing.T) { + ttl := 300 + pdp := mockPDP(t, "ALLOW", "dec-ttl-1", "", &ttl, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, int32(300), resp.Ttl) +} + +func TestEvaluatePolicyDecision_ZeroTimeout(t *testing.T) { + pdp := mockPDP(t, "ALLOW", "dec-timeout-0", "", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.PdpTimeoutMs = 0 // Should default to 500ms, not hang + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp.Decision) +} + +func TestEvaluatePolicyDecision_AllEnforcementModes(t *testing.T) { + pdp := mockPDP(t, "DENY", "dec-em-test", "policy denied", nil, nil) + defer pdp.Close() + + tests := []struct { + mode string + expected string + }{ + {"EM-OBSERVE", pip.DecisionObserve}, + {"EM-GUARD", pip.DecisionDeny}, + {"EM-DELEGATE", pip.DecisionDeny}, + {"EM-STRICT", pip.DecisionDeny}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.EnforcementMode = tt.mode + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, tt.expected, resp.Decision, "mode=%s", tt.mode) + }) + } +} + +func TestEvaluatePolicyDecision_TxnIDIsUUID(t *testing.T) { + svc := newTestService(t) + + req := &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{Did: "did:web:test"}, + Action: &pb.PolicyAction{Operation: "test"}, + Resource: &pb.PolicyResource{Identifier: "/test"}, + } + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Len(t, resp.TxnId, 36, "TxnID should be a UUID (36 chars with hyphens)") + assert.Contains(t, resp.TxnId, "-") +} + +func TestEvaluatePolicyDecision_PDPSeesCorrectRequest(t *testing.T) { + var receivedReq pip.DecisionRequest + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedReq) + resp := pip.DecisionResponse{Decision: "ALLOW", DecisionID: "dec-verify"} + json.NewEncoder(w).Encode(resp) + })) + defer pdp.Close() + + svc := newTestService(t) + req := &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{ + Did: "did:web:agent.test", + BadgeJti: "jti-456", + Ial: "ial-2", + TrustLevel: "3", + }, + Action: &pb.PolicyAction{ + Operation: "write_file", + }, + Resource: &pb.PolicyResource{ + Identifier: "/data/output.json", + }, + Config: &pb.PolicyConfig{ + PdpEndpoint: pdp.URL, + PdpTimeoutMs: 500, + EnforcementMode: "EM-DELEGATE", + PepId: "pep-test-1", + Workspace: "ws-prod", + }, + } + + _, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + + // Verify PIP request structure + assert.Equal(t, pip.PIPVersion, receivedReq.PIPVersion) + assert.Equal(t, "did:web:agent.test", receivedReq.Subject.DID) + assert.Equal(t, "jti-456", receivedReq.Subject.BadgeJTI) + assert.Equal(t, "ial-2", receivedReq.Subject.IAL) + assert.Equal(t, "3", receivedReq.Subject.TrustLevel) + assert.Equal(t, "write_file", receivedReq.Action.Operation) + assert.Equal(t, "/data/output.json", receivedReq.Resource.Identifier) + assert.Equal(t, "EM-DELEGATE", receivedReq.Context.EnforcementMode) + assert.NotEmpty(t, receivedReq.Context.TxnID) + assert.Equal(t, "pep-test-1", *receivedReq.Environment.PEPID) + assert.Equal(t, "ws-prod", *receivedReq.Environment.Workspace) +} + +func TestObligationsToProto(t *testing.T) { + t.Run("nil obligations", func(t *testing.T) { + assert.Nil(t, obligationsToProto(nil)) + }) + + t.Run("empty obligations", func(t *testing.T) { + assert.Nil(t, obligationsToProto([]pip.Obligation{})) + }) + + t.Run("with obligations", func(t *testing.T) { + obs := []pip.Obligation{ + {Type: "rate_limit", Params: json.RawMessage(`{"max": 100}`)}, + } + result := obligationsToProto(obs) + require.Len(t, result, 1) + assert.Equal(t, "rate_limit", result[0].Type) + assert.Equal(t, `{"max": 100}`, result[0].ParamsJson) + }) +} + +// --------------------------------------------------------------------------- +// Break-glass helpers and tests +// --------------------------------------------------------------------------- + +// signBreakGlassJWS creates a compact JWS signed with the given Ed25519 private key. +func signBreakGlassJWS(t *testing.T, privKey ed25519.PrivateKey, token *pip.BreakGlassToken) string { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: privKey}, nil) + require.NoError(t, err, "create JWS signer") + + payload, err := json.Marshal(token) + require.NoError(t, err, "marshal break-glass token") + + jws, err := signer.Sign(payload) + require.NoError(t, err, "sign break-glass token") + + compact, err := jws.CompactSerialize() + require.NoError(t, err, "serialize JWS") + return compact +} + +func generateBGKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err, "generate ed25519 key pair") + return pub, priv +} + +func validBreakGlassToken() *pip.BreakGlassToken { + return &pip.BreakGlassToken{ + JTI: "bg-test-001", + IAT: time.Now().Add(-1 * time.Minute).Unix(), + EXP: time.Now().Add(10 * time.Minute).Unix(), + ISS: "admin@example.com", + SUB: "oncall-operator", + Reason: "emergency database maintenance", + Scope: pip.BreakGlassScope{Methods: []string{"*"}, Routes: []string{"*"}}, + } +} + +func TestEvaluatePolicyDecision_BreakGlass_Valid(t *testing.T) { + pubKey, privKey := generateBGKeyPair(t) + + pdp := mockPDP(t, "DENY", "dec-deny-bg", "denied by policy", nil, nil) + defer pdp.Close() + + bgToken := validBreakGlassToken() + jws := signBreakGlassJWS(t, privKey, bgToken) + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.BreakglassPublicKey = []byte(pubKey) + req.BreakglassToken = jws + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, pip.DecisionAllow, resp.Decision) + assert.True(t, resp.BreakglassOverride) + assert.Equal(t, "bg-test-001", resp.BreakglassJti) + assert.Equal(t, "emergency database maintenance", resp.Reason) + assert.Contains(t, resp.DecisionId, "breakglass:") +} + +func TestEvaluatePolicyDecision_BreakGlass_WrongKey(t *testing.T) { + _, privKey := generateBGKeyPair(t) + wrongPub, _ := generateBGKeyPair(t) // different key pair + + pdp := mockPDP(t, "ALLOW", "dec-allow-bg", "", nil, nil) + defer pdp.Close() + + bgToken := validBreakGlassToken() + jws := signBreakGlassJWS(t, privKey, bgToken) + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.BreakglassPublicKey = []byte(wrongPub) // wrong key + req.BreakglassToken = jws + + // Break-glass fails silently, falls through to normal PDP path + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.False(t, resp.BreakglassOverride) + assert.Equal(t, pip.DecisionAllow, resp.Decision) // PDP allows + assert.Equal(t, "dec-allow-bg", resp.DecisionId) // from PDP, not break-glass +} + +func TestEvaluatePolicyDecision_BreakGlass_NoKeyConfigured(t *testing.T) { + pdp := mockPDP(t, "ALLOW", "dec-allow-nokey", "", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + // No BreakglassPublicKey configured + req.BreakglassToken = "some-token" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.False(t, resp.BreakglassOverride) + assert.Equal(t, "dec-allow-nokey", resp.DecisionId) // fell through to PDP +} + +func TestEvaluatePolicyDecision_BreakGlass_ScopeMismatch(t *testing.T) { + pubKey, privKey := generateBGKeyPair(t) + + pdp := mockPDP(t, "DENY", "dec-deny-scope", "denied", nil, nil) + defer pdp.Close() + + bgToken := validBreakGlassToken() + bgToken.Scope = pip.BreakGlassScope{ + Methods: []string{"*"}, + Routes: []string{"/admin/*"}, // does NOT cover "read_file" + } + jws := signBreakGlassJWS(t, privKey, bgToken) + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.BreakglassPublicKey = []byte(pubKey) + req.BreakglassToken = jws + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.False(t, resp.BreakglassOverride) + assert.Equal(t, pip.DecisionDeny, resp.Decision) // PDP DENY, scope didn't match +} + +func TestEvaluatePolicyDecision_BreakGlass_BadKeySize(t *testing.T) { + pdp := mockPDP(t, "ALLOW", "dec-allow-badkey", "", nil, nil) + defer pdp.Close() + + svc := newTestService(t) + req := basicRequest(pdp.URL) + req.Config.BreakglassPublicKey = []byte("too-short") // wrong size + req.BreakglassToken = "some-token" + + resp, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.NoError(t, err) + assert.False(t, resp.BreakglassOverride) + assert.Equal(t, "dec-allow-badkey", resp.DecisionId) // fell through to PDP +} + +func TestEvaluatePolicyDecision_InvalidEnforcementMode_NoPDP(t *testing.T) { + svc := newTestService(t) + req := &pb.PolicyDecisionRequest{ + Subject: &pb.PolicySubject{Did: "did:web:test"}, + Action: &pb.PolicyAction{Operation: "test"}, + Resource: &pb.PolicyResource{Identifier: "/test"}, + Config: &pb.PolicyConfig{EnforcementMode: "INVALID"}, + } + + _, err := svc.EvaluatePolicyDecision(context.Background(), req) + require.Error(t, err, "Invalid enforcement mode should error even without PDP") + assert.Contains(t, err.Error(), "invalid enforcement_mode") +} + +func TestClassifyPDPError(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + {"context deadline", context.DeadlineExceeded, "pdp_timeout"}, + {"context canceled", context.Canceled, "pdp_unavailable"}, + {"invalid PDP response", &invalidPDPResponseError{decision: "MAYBE"}, "pdp_invalid_response"}, + {"invalid decision message", fmt.Errorf("pip: pdp returned invalid decision %q", "MAYBE"), "pdp_invalid_response"}, + {"empty decision_id message", fmt.Errorf("pip: pdp returned empty decision_id"), "pdp_invalid_response"}, + {"unmarshal error", fmt.Errorf("pip: unmarshal pdp response: invalid json"), "pdp_invalid_response"}, + {"generic network error", fmt.Errorf("connection refused"), "pdp_unavailable"}, + {"wrapped timeout", fmt.Errorf("pip: pdp request failed: %w", context.DeadlineExceeded), "pdp_timeout"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, classifyPDPError(tt.err)) + }) + } +} diff --git a/pkg/pip/cache.go b/pkg/pip/cache.go index 91a445b..13cf37e 100644 --- a/pkg/pip/cache.go +++ b/pkg/pip/cache.go @@ -22,11 +22,11 @@ type DecisionCache interface { } // CacheKeyComponents builds a deterministic cache key from PIP request fields. -// Key includes: subject.did + subject.badge_jti + action.operation + resource.identifier. -func CacheKeyComponents(did, badgeJTI, operation, resourceID string) string { +// Key includes: subject.did + subject.badge_jti + action.operation + resource.identifier + enforcement_mode. +func CacheKeyComponents(did, badgeJTI, operation, resourceID string, extra ...string) string { h := sha256.New() // Use a separator that cannot appear in DIDs or operation strings - for _, s := range []string{did, badgeJTI, operation, resourceID} { + for _, s := range append([]string{did, badgeJTI, operation, resourceID}, extra...) { fmt.Fprintf(h, "%s\x00", s) } return fmt.Sprintf("%x", h.Sum(nil)) diff --git a/pkg/rpc/gen/capiscio/v1/mcp.pb.go b/pkg/rpc/gen/capiscio/v1/mcp.pb.go index 7f7b570..7925057 100644 --- a/pkg/rpc/gen/capiscio/v1/mcp.pb.go +++ b/pkg/rpc/gen/capiscio/v1/mcp.pb.go @@ -790,6 +790,520 @@ func (x *MCPObligation) GetParamsJson() string { return "" } +// Request for centralized policy decision. +// The Go core handles: PDP query, decision cache, break-glass override, +// enforcement mode logic, and telemetry emission. +// The SDK caller handles: obligation execution, response propagation, +// and surface-specific error handling. +type PolicyDecisionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Subject identity (from badge verification, already completed by SDK) + Subject *PolicySubject `protobuf:"bytes,1,opt,name=subject,proto3" json:"subject,omitempty"` + // What is being attempted + Action *PolicyAction `protobuf:"bytes,2,opt,name=action,proto3" json:"action,omitempty"` + // Target resource + Resource *PolicyResource `protobuf:"bytes,3,opt,name=resource,proto3" json:"resource,omitempty"` + // PDP and PEP configuration + Config *PolicyConfig `protobuf:"bytes,4,opt,name=config,proto3" json:"config,omitempty"` + // Optional break-glass override token (compact JWS, EdDSA) + BreakglassToken string `protobuf:"bytes,5,opt,name=breakglass_token,json=breakglassToken,proto3" json:"breakglass_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyDecisionRequest) Reset() { + *x = PolicyDecisionRequest{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyDecisionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyDecisionRequest) ProtoMessage() {} + +func (x *PolicyDecisionRequest) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[4] + 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 PolicyDecisionRequest.ProtoReflect.Descriptor instead. +func (*PolicyDecisionRequest) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{4} +} + +func (x *PolicyDecisionRequest) GetSubject() *PolicySubject { + if x != nil { + return x.Subject + } + return nil +} + +func (x *PolicyDecisionRequest) GetAction() *PolicyAction { + if x != nil { + return x.Action + } + return nil +} + +func (x *PolicyDecisionRequest) GetResource() *PolicyResource { + if x != nil { + return x.Resource + } + return nil +} + +func (x *PolicyDecisionRequest) GetConfig() *PolicyConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *PolicyDecisionRequest) GetBreakglassToken() string { + if x != nil { + return x.BreakglassToken + } + return "" +} + +// Subject attributes for policy evaluation. +// SDK extracts these from the verified badge before calling this RPC. +type PolicySubject struct { + state protoimpl.MessageState `protogen:"open.v1"` + Did string `protobuf:"bytes,1,opt,name=did,proto3" json:"did,omitempty"` // Badge sub (agent DID) + BadgeJti string `protobuf:"bytes,2,opt,name=badge_jti,json=badgeJti,proto3" json:"badge_jti,omitempty"` // Badge jti + Ial string `protobuf:"bytes,3,opt,name=ial,proto3" json:"ial,omitempty"` // Badge ial + TrustLevel string `protobuf:"bytes,4,opt,name=trust_level,json=trustLevel,proto3" json:"trust_level,omitempty"` // Badge vc.credentialSubject.level ("1", "2", "3") + BadgeExp int64 `protobuf:"varint,5,opt,name=badge_exp,json=badgeExp,proto3" json:"badge_exp,omitempty"` // Badge exp (Unix seconds) — bounds cache TTL + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicySubject) Reset() { + *x = PolicySubject{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicySubject) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicySubject) ProtoMessage() {} + +func (x *PolicySubject) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[5] + 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 PolicySubject.ProtoReflect.Descriptor instead. +func (*PolicySubject) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{5} +} + +func (x *PolicySubject) GetDid() string { + if x != nil { + return x.Did + } + return "" +} + +func (x *PolicySubject) GetBadgeJti() string { + if x != nil { + return x.BadgeJti + } + return "" +} + +func (x *PolicySubject) GetIal() string { + if x != nil { + return x.Ial + } + return "" +} + +func (x *PolicySubject) GetTrustLevel() string { + if x != nil { + return x.TrustLevel + } + return "" +} + +func (x *PolicySubject) GetBadgeExp() int64 { + if x != nil { + return x.BadgeExp + } + return 0 +} + +// Action attributes for policy evaluation. +type PolicyAction struct { + state protoimpl.MessageState `protogen:"open.v1"` + Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` // tool name, HTTP method+route, etc. + CapabilityClass string `protobuf:"bytes,2,opt,name=capability_class,json=capabilityClass,proto3" json:"capability_class,omitempty"` // empty in badge-only mode (RFC-008) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyAction) Reset() { + *x = PolicyAction{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyAction) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyAction) ProtoMessage() {} + +func (x *PolicyAction) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[6] + 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 PolicyAction.ProtoReflect.Descriptor instead. +func (*PolicyAction) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{6} +} + +func (x *PolicyAction) GetOperation() string { + if x != nil { + return x.Operation + } + return "" +} + +func (x *PolicyAction) GetCapabilityClass() string { + if x != nil { + return x.CapabilityClass + } + return "" +} + +// Resource attributes for policy evaluation. +type PolicyResource struct { + state protoimpl.MessageState `protogen:"open.v1"` + Identifier string `protobuf:"bytes,1,opt,name=identifier,proto3" json:"identifier,omitempty"` // target resource URI + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyResource) Reset() { + *x = PolicyResource{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyResource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyResource) ProtoMessage() {} + +func (x *PolicyResource) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[7] + 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 PolicyResource.ProtoReflect.Descriptor instead. +func (*PolicyResource) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{7} +} + +func (x *PolicyResource) GetIdentifier() string { + if x != nil { + return x.Identifier + } + return "" +} + +// PEP-level configuration for the policy decision. +type PolicyConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // PDP endpoint URL. If empty, RPC returns ALLOW (badge-only mode). + PdpEndpoint string `protobuf:"bytes,1,opt,name=pdp_endpoint,json=pdpEndpoint,proto3" json:"pdp_endpoint,omitempty"` + // PDP query timeout in milliseconds. 0 or negative → 500ms default. + PdpTimeoutMs int32 `protobuf:"varint,2,opt,name=pdp_timeout_ms,json=pdpTimeoutMs,proto3" json:"pdp_timeout_ms,omitempty"` + // Enforcement mode: EM-OBSERVE, EM-GUARD, EM-DELEGATE, EM-STRICT. + // Empty → EM-OBSERVE. + EnforcementMode string `protobuf:"bytes,3,opt,name=enforcement_mode,json=enforcementMode,proto3" json:"enforcement_mode,omitempty"` + // PEP identifier (included in PDP requests for audit). + PepId string `protobuf:"bytes,4,opt,name=pep_id,json=pepId,proto3" json:"pep_id,omitempty"` + // Workspace identifier (included in PDP requests). + Workspace string `protobuf:"bytes,5,opt,name=workspace,proto3" json:"workspace,omitempty"` + // Break-glass Ed25519 public key (raw 32 bytes). + // Must be separate from CA badge-signing key. + // Server-side configuration provides the key material directly; + // no filesystem paths cross the RPC boundary. + BreakglassPublicKey []byte `protobuf:"bytes,6,opt,name=breakglass_public_key,json=breakglassPublicKey,proto3" json:"breakglass_public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyConfig) Reset() { + *x = PolicyConfig{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyConfig) ProtoMessage() {} + +func (x *PolicyConfig) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[8] + 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 PolicyConfig.ProtoReflect.Descriptor instead. +func (*PolicyConfig) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{8} +} + +func (x *PolicyConfig) GetPdpEndpoint() string { + if x != nil { + return x.PdpEndpoint + } + return "" +} + +func (x *PolicyConfig) GetPdpTimeoutMs() int32 { + if x != nil { + return x.PdpTimeoutMs + } + return 0 +} + +func (x *PolicyConfig) GetEnforcementMode() string { + if x != nil { + return x.EnforcementMode + } + return "" +} + +func (x *PolicyConfig) GetPepId() string { + if x != nil { + return x.PepId + } + return "" +} + +func (x *PolicyConfig) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +func (x *PolicyConfig) GetBreakglassPublicKey() []byte { + if x != nil { + return x.BreakglassPublicKey + } + return nil +} + +// Response from centralized policy decision. +// This is ALWAYS a successful RPC response — PDP unreachability is encoded +// in the response fields, never as a gRPC error. SDKs should not need to +// distinguish transport errors from policy outcomes. +type PolicyDecisionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Policy decision: "ALLOW", "DENY", or "ALLOW_OBSERVE". + // ALLOW_OBSERVE indicates PDP was unreachable in EM-OBSERVE mode. + Decision string `protobuf:"bytes,1,opt,name=decision,proto3" json:"decision,omitempty"` + // Globally unique decision ID from the PDP. + // Synthetic IDs (e.g., "pdp-unavailable", "breakglass-override", "cache-hit") + // are used when the PDP was not consulted. + DecisionId string `protobuf:"bytes,2,opt,name=decision_id,json=decisionId,proto3" json:"decision_id,omitempty"` + // Human-readable reason (populated on DENY or when PDP provides one). + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + // Cache TTL in seconds from PDP response. 0 if not cacheable. + Ttl int32 `protobuf:"varint,4,opt,name=ttl,proto3" json:"ttl,omitempty"` + // Obligations the SDK must execute. Obligation *decision* and *registry + // enforcement* is done by the Go core per the EM matrix. Only obligations + // that the core determined should proceed are returned here. + // For EM-OBSERVE: all obligations are returned (for logging). + // For EM-STRICT: only if all known, all succeeded in core pre-check. + Obligations []*MCPObligation `protobuf:"bytes,5,rep,name=obligations,proto3" json:"obligations,omitempty"` + // Enforcement mode that was applied for this decision. + EnforcementMode string `protobuf:"bytes,6,opt,name=enforcement_mode,json=enforcementMode,proto3" json:"enforcement_mode,omitempty"` + // Whether this decision came from cache (vs live PDP query). + CacheHit bool `protobuf:"varint,7,opt,name=cache_hit,json=cacheHit,proto3" json:"cache_hit,omitempty"` + // Whether a break-glass override was applied. + BreakglassOverride bool `protobuf:"varint,8,opt,name=breakglass_override,json=breakglassOverride,proto3" json:"breakglass_override,omitempty"` + // Break-glass token JTI (for audit trail, only set when override applied). + BreakglassJti string `protobuf:"bytes,9,opt,name=breakglass_jti,json=breakglassJti,proto3" json:"breakglass_jti,omitempty"` + // Error code when PDP could not be consulted. + // Empty string when PDP responded normally. + // Values: "pdp_unavailable", "pdp_timeout", "pdp_invalid_response". + ErrorCode string `protobuf:"bytes,10,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + // PDP query latency in milliseconds (0 if cache hit or PDP not consulted). + PdpLatencyMs int64 `protobuf:"varint,11,opt,name=pdp_latency_ms,json=pdpLatencyMs,proto3" json:"pdp_latency_ms,omitempty"` + // Transaction ID (UUID v7) assigned to this decision. + TxnId string `protobuf:"bytes,12,opt,name=txn_id,json=txnId,proto3" json:"txn_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyDecisionResponse) Reset() { + *x = PolicyDecisionResponse{} + mi := &file_capiscio_v1_mcp_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyDecisionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyDecisionResponse) ProtoMessage() {} + +func (x *PolicyDecisionResponse) ProtoReflect() protoreflect.Message { + mi := &file_capiscio_v1_mcp_proto_msgTypes[9] + 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 PolicyDecisionResponse.ProtoReflect.Descriptor instead. +func (*PolicyDecisionResponse) Descriptor() ([]byte, []int) { + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{9} +} + +func (x *PolicyDecisionResponse) GetDecision() string { + if x != nil { + return x.Decision + } + return "" +} + +func (x *PolicyDecisionResponse) GetDecisionId() string { + if x != nil { + return x.DecisionId + } + return "" +} + +func (x *PolicyDecisionResponse) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *PolicyDecisionResponse) GetTtl() int32 { + if x != nil { + return x.Ttl + } + return 0 +} + +func (x *PolicyDecisionResponse) GetObligations() []*MCPObligation { + if x != nil { + return x.Obligations + } + return nil +} + +func (x *PolicyDecisionResponse) GetEnforcementMode() string { + if x != nil { + return x.EnforcementMode + } + return "" +} + +func (x *PolicyDecisionResponse) GetCacheHit() bool { + if x != nil { + return x.CacheHit + } + return false +} + +func (x *PolicyDecisionResponse) GetBreakglassOverride() bool { + if x != nil { + return x.BreakglassOverride + } + return false +} + +func (x *PolicyDecisionResponse) GetBreakglassJti() string { + if x != nil { + return x.BreakglassJti + } + return "" +} + +func (x *PolicyDecisionResponse) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *PolicyDecisionResponse) GetPdpLatencyMs() int64 { + if x != nil { + return x.PdpLatencyMs + } + return 0 +} + +func (x *PolicyDecisionResponse) GetTxnId() string { + if x != nil { + return x.TxnId + } + return "" +} + // Request to verify server identity type VerifyServerIdentityRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -809,7 +1323,7 @@ type VerifyServerIdentityRequest struct { func (x *VerifyServerIdentityRequest) Reset() { *x = VerifyServerIdentityRequest{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[4] + mi := &file_capiscio_v1_mcp_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -821,7 +1335,7 @@ func (x *VerifyServerIdentityRequest) String() string { func (*VerifyServerIdentityRequest) ProtoMessage() {} func (x *VerifyServerIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[4] + mi := &file_capiscio_v1_mcp_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -834,7 +1348,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{4} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{10} } func (x *VerifyServerIdentityRequest) GetServerDid() string { @@ -891,7 +1405,7 @@ type MCPVerifyConfig struct { func (x *MCPVerifyConfig) Reset() { *x = MCPVerifyConfig{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[5] + mi := &file_capiscio_v1_mcp_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -903,7 +1417,7 @@ func (x *MCPVerifyConfig) String() string { func (*MCPVerifyConfig) ProtoMessage() {} func (x *MCPVerifyConfig) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[5] + mi := &file_capiscio_v1_mcp_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -916,7 +1430,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{5} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{11} } func (x *MCPVerifyConfig) GetTrustedIssuers() []string { @@ -975,7 +1489,7 @@ type VerifyServerIdentityResponse struct { func (x *VerifyServerIdentityResponse) Reset() { *x = VerifyServerIdentityResponse{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[6] + mi := &file_capiscio_v1_mcp_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -987,7 +1501,7 @@ func (x *VerifyServerIdentityResponse) String() string { func (*VerifyServerIdentityResponse) ProtoMessage() {} func (x *VerifyServerIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[6] + mi := &file_capiscio_v1_mcp_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1000,7 +1514,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{6} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{12} } func (x *VerifyServerIdentityResponse) GetState() MCPServerState { @@ -1059,7 +1573,7 @@ type ParseServerIdentityRequest struct { func (x *ParseServerIdentityRequest) Reset() { *x = ParseServerIdentityRequest{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[7] + mi := &file_capiscio_v1_mcp_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1071,7 +1585,7 @@ func (x *ParseServerIdentityRequest) String() string { func (*ParseServerIdentityRequest) ProtoMessage() {} func (x *ParseServerIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[7] + mi := &file_capiscio_v1_mcp_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1084,7 +1598,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{7} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{13} } func (x *ParseServerIdentityRequest) GetSource() isParseServerIdentityRequest_Source { @@ -1139,7 +1653,7 @@ type MCPHttpHeaders struct { func (x *MCPHttpHeaders) Reset() { *x = MCPHttpHeaders{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[8] + mi := &file_capiscio_v1_mcp_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1151,7 +1665,7 @@ func (x *MCPHttpHeaders) String() string { func (*MCPHttpHeaders) ProtoMessage() {} func (x *MCPHttpHeaders) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[8] + mi := &file_capiscio_v1_mcp_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1164,7 +1678,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{8} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{14} } func (x *MCPHttpHeaders) GetCapiscioServerDid() string { @@ -1192,7 +1706,7 @@ type MCPJsonRpcMeta struct { func (x *MCPJsonRpcMeta) Reset() { *x = MCPJsonRpcMeta{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[9] + mi := &file_capiscio_v1_mcp_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1204,7 +1718,7 @@ func (x *MCPJsonRpcMeta) String() string { func (*MCPJsonRpcMeta) ProtoMessage() {} func (x *MCPJsonRpcMeta) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[9] + mi := &file_capiscio_v1_mcp_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1217,7 +1731,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{9} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{15} } func (x *MCPJsonRpcMeta) GetMetaJson() string { @@ -1242,7 +1756,7 @@ type ParseServerIdentityResponse struct { func (x *ParseServerIdentityResponse) Reset() { *x = ParseServerIdentityResponse{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[10] + mi := &file_capiscio_v1_mcp_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1254,7 +1768,7 @@ func (x *ParseServerIdentityResponse) String() string { func (*ParseServerIdentityResponse) ProtoMessage() {} func (x *ParseServerIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[10] + mi := &file_capiscio_v1_mcp_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1267,7 +1781,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{10} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{16} } func (x *ParseServerIdentityResponse) GetServerDid() string { @@ -1302,7 +1816,7 @@ type MCPHealthRequest struct { func (x *MCPHealthRequest) Reset() { *x = MCPHealthRequest{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[11] + mi := &file_capiscio_v1_mcp_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1314,7 +1828,7 @@ func (x *MCPHealthRequest) String() string { func (*MCPHealthRequest) ProtoMessage() {} func (x *MCPHealthRequest) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[11] + mi := &file_capiscio_v1_mcp_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1327,7 +1841,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{11} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{17} } func (x *MCPHealthRequest) GetClientVersion() string { @@ -1354,7 +1868,7 @@ type MCPHealthResponse struct { func (x *MCPHealthResponse) Reset() { *x = MCPHealthResponse{} - mi := &file_capiscio_v1_mcp_proto_msgTypes[12] + mi := &file_capiscio_v1_mcp_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1366,7 +1880,7 @@ func (x *MCPHealthResponse) String() string { func (*MCPHealthResponse) ProtoMessage() {} func (x *MCPHealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_capiscio_v1_mcp_proto_msgTypes[12] + mi := &file_capiscio_v1_mcp_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1379,7 +1893,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{12} + return file_capiscio_v1_mcp_proto_rawDescGZIP(), []int{18} } func (x *MCPHealthResponse) GetHealthy() bool { @@ -1463,7 +1977,50 @@ const file_capiscio_v1_mcp_proto_rawDesc = "" + "\rMCPObligation\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x1f\n" + "\vparams_json\x18\x02 \x01(\tR\n" + - "paramsJson\"\xe5\x01\n" + + "paramsJson\"\x97\x02\n" + + "\x15PolicyDecisionRequest\x124\n" + + "\asubject\x18\x01 \x01(\v2\x1a.capiscio.v1.PolicySubjectR\asubject\x121\n" + + "\x06action\x18\x02 \x01(\v2\x19.capiscio.v1.PolicyActionR\x06action\x127\n" + + "\bresource\x18\x03 \x01(\v2\x1b.capiscio.v1.PolicyResourceR\bresource\x121\n" + + "\x06config\x18\x04 \x01(\v2\x19.capiscio.v1.PolicyConfigR\x06config\x12)\n" + + "\x10breakglass_token\x18\x05 \x01(\tR\x0fbreakglassToken\"\x8e\x01\n" + + "\rPolicySubject\x12\x10\n" + + "\x03did\x18\x01 \x01(\tR\x03did\x12\x1b\n" + + "\tbadge_jti\x18\x02 \x01(\tR\bbadgeJti\x12\x10\n" + + "\x03ial\x18\x03 \x01(\tR\x03ial\x12\x1f\n" + + "\vtrust_level\x18\x04 \x01(\tR\n" + + "trustLevel\x12\x1b\n" + + "\tbadge_exp\x18\x05 \x01(\x03R\bbadgeExp\"W\n" + + "\fPolicyAction\x12\x1c\n" + + "\toperation\x18\x01 \x01(\tR\toperation\x12)\n" + + "\x10capability_class\x18\x02 \x01(\tR\x0fcapabilityClass\"0\n" + + "\x0ePolicyResource\x12\x1e\n" + + "\n" + + "identifier\x18\x01 \x01(\tR\n" + + "identifier\"\xeb\x01\n" + + "\fPolicyConfig\x12!\n" + + "\fpdp_endpoint\x18\x01 \x01(\tR\vpdpEndpoint\x12$\n" + + "\x0epdp_timeout_ms\x18\x02 \x01(\x05R\fpdpTimeoutMs\x12)\n" + + "\x10enforcement_mode\x18\x03 \x01(\tR\x0fenforcementMode\x12\x15\n" + + "\x06pep_id\x18\x04 \x01(\tR\x05pepId\x12\x1c\n" + + "\tworkspace\x18\x05 \x01(\tR\tworkspace\x122\n" + + "\x15breakglass_public_key\x18\x06 \x01(\fR\x13breakglassPublicKey\"\xb9\x03\n" + + "\x16PolicyDecisionResponse\x12\x1a\n" + + "\bdecision\x18\x01 \x01(\tR\bdecision\x12\x1f\n" + + "\vdecision_id\x18\x02 \x01(\tR\n" + + "decisionId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x10\n" + + "\x03ttl\x18\x04 \x01(\x05R\x03ttl\x12<\n" + + "\vobligations\x18\x05 \x03(\v2\x1a.capiscio.v1.MCPObligationR\vobligations\x12)\n" + + "\x10enforcement_mode\x18\x06 \x01(\tR\x0fenforcementMode\x12\x1b\n" + + "\tcache_hit\x18\a \x01(\bR\bcacheHit\x12/\n" + + "\x13breakglass_override\x18\b \x01(\bR\x12breakglassOverride\x12%\n" + + "\x0ebreakglass_jti\x18\t \x01(\tR\rbreakglassJti\x12\x1d\n" + + "\n" + + "error_code\x18\n" + + " \x01(\tR\terrorCode\x12$\n" + + "\x0epdp_latency_ms\x18\v \x01(\x03R\fpdpLatencyMs\x12\x15\n" + + "\x06txn_id\x18\f \x01(\tR\x05txnId\"\xe5\x01\n" + "\x1bVerifyServerIdentityRequest\x12\x1d\n" + "\n" + "server_did\x18\x01 \x01(\tR\tserverDid\x12!\n" + @@ -1541,10 +2098,11 @@ const file_capiscio_v1_mcp_proto_rawDesc = "" + "#MCP_SERVER_ERROR_TRUST_INSUFFICIENT\x10\x05\x12$\n" + " MCP_SERVER_ERROR_ORIGIN_MISMATCH\x10\x06\x12\"\n" + "\x1eMCP_SERVER_ERROR_PATH_MISMATCH\x10\a\x12%\n" + - "!MCP_SERVER_ERROR_ISSUER_UNTRUSTED\x10\b2\x93\x03\n" + + "!MCP_SERVER_ERROR_ISSUER_UNTRUSTED\x10\b2\xf6\x03\n" + "\n" + "MCPService\x12e\n" + - "\x12EvaluateToolAccess\x12&.capiscio.v1.EvaluateToolAccessRequest\x1a'.capiscio.v1.EvaluateToolAccessResponse\x12k\n" + + "\x12EvaluateToolAccess\x12&.capiscio.v1.EvaluateToolAccessRequest\x1a'.capiscio.v1.EvaluateToolAccessResponse\x12a\n" + + "\x16EvaluatePolicyDecision\x12\".capiscio.v1.PolicyDecisionRequest\x1a#.capiscio.v1.PolicyDecisionResponse\x12k\n" + "\x14VerifyServerIdentity\x12(.capiscio.v1.VerifyServerIdentityRequest\x1a).capiscio.v1.VerifyServerIdentityResponse\x12h\n" + "\x13ParseServerIdentity\x12'.capiscio.v1.ParseServerIdentityRequest\x1a(.capiscio.v1.ParseServerIdentityResponse\x12G\n" + "\x06Health\x12\x1d.capiscio.v1.MCPHealthRequest\x1a\x1e.capiscio.v1.MCPHealthResponseB\xae\x01\n" + @@ -1563,7 +2121,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, 13) +var file_capiscio_v1_mcp_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_capiscio_v1_mcp_proto_goTypes = []any{ (MCPDecision)(0), // 0: capiscio.v1.MCPDecision (MCPAuthLevel)(0), // 1: capiscio.v1.MCPAuthLevel @@ -1574,42 +2132,55 @@ var file_capiscio_v1_mcp_proto_goTypes = []any{ (*EvaluateConfig)(nil), // 6: capiscio.v1.EvaluateConfig (*EvaluateToolAccessResponse)(nil), // 7: capiscio.v1.EvaluateToolAccessResponse (*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 + (*PolicyDecisionRequest)(nil), // 9: capiscio.v1.PolicyDecisionRequest + (*PolicySubject)(nil), // 10: capiscio.v1.PolicySubject + (*PolicyAction)(nil), // 11: capiscio.v1.PolicyAction + (*PolicyResource)(nil), // 12: capiscio.v1.PolicyResource + (*PolicyConfig)(nil), // 13: capiscio.v1.PolicyConfig + (*PolicyDecisionResponse)(nil), // 14: capiscio.v1.PolicyDecisionResponse + (*VerifyServerIdentityRequest)(nil), // 15: capiscio.v1.VerifyServerIdentityRequest + (*MCPVerifyConfig)(nil), // 16: capiscio.v1.MCPVerifyConfig + (*VerifyServerIdentityResponse)(nil), // 17: capiscio.v1.VerifyServerIdentityResponse + (*ParseServerIdentityRequest)(nil), // 18: capiscio.v1.ParseServerIdentityRequest + (*MCPHttpHeaders)(nil), // 19: capiscio.v1.MCPHttpHeaders + (*MCPJsonRpcMeta)(nil), // 20: capiscio.v1.MCPJsonRpcMeta + (*ParseServerIdentityResponse)(nil), // 21: capiscio.v1.ParseServerIdentityResponse + (*MCPHealthRequest)(nil), // 22: capiscio.v1.MCPHealthRequest + (*MCPHealthResponse)(nil), // 23: capiscio.v1.MCPHealthResponse + (*timestamppb.Timestamp)(nil), // 24: 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 - 18, // 4: capiscio.v1.EvaluateToolAccessResponse.timestamp:type_name -> google.protobuf.Timestamp + 24, // 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 + 10, // 6: capiscio.v1.PolicyDecisionRequest.subject:type_name -> capiscio.v1.PolicySubject + 11, // 7: capiscio.v1.PolicyDecisionRequest.action:type_name -> capiscio.v1.PolicyAction + 12, // 8: capiscio.v1.PolicyDecisionRequest.resource:type_name -> capiscio.v1.PolicyResource + 13, // 9: capiscio.v1.PolicyDecisionRequest.config:type_name -> capiscio.v1.PolicyConfig + 8, // 10: capiscio.v1.PolicyDecisionResponse.obligations:type_name -> capiscio.v1.MCPObligation + 16, // 11: capiscio.v1.VerifyServerIdentityRequest.config:type_name -> capiscio.v1.MCPVerifyConfig + 3, // 12: capiscio.v1.VerifyServerIdentityResponse.state:type_name -> capiscio.v1.MCPServerState + 4, // 13: capiscio.v1.VerifyServerIdentityResponse.error_code:type_name -> capiscio.v1.MCPServerErrorCode + 19, // 14: capiscio.v1.ParseServerIdentityRequest.http_headers:type_name -> capiscio.v1.MCPHttpHeaders + 20, // 15: capiscio.v1.ParseServerIdentityRequest.jsonrpc_meta:type_name -> capiscio.v1.MCPJsonRpcMeta + 5, // 16: capiscio.v1.MCPService.EvaluateToolAccess:input_type -> capiscio.v1.EvaluateToolAccessRequest + 9, // 17: capiscio.v1.MCPService.EvaluatePolicyDecision:input_type -> capiscio.v1.PolicyDecisionRequest + 15, // 18: capiscio.v1.MCPService.VerifyServerIdentity:input_type -> capiscio.v1.VerifyServerIdentityRequest + 18, // 19: capiscio.v1.MCPService.ParseServerIdentity:input_type -> capiscio.v1.ParseServerIdentityRequest + 22, // 20: capiscio.v1.MCPService.Health:input_type -> capiscio.v1.MCPHealthRequest + 7, // 21: capiscio.v1.MCPService.EvaluateToolAccess:output_type -> capiscio.v1.EvaluateToolAccessResponse + 14, // 22: capiscio.v1.MCPService.EvaluatePolicyDecision:output_type -> capiscio.v1.PolicyDecisionResponse + 17, // 23: capiscio.v1.MCPService.VerifyServerIdentity:output_type -> capiscio.v1.VerifyServerIdentityResponse + 21, // 24: capiscio.v1.MCPService.ParseServerIdentity:output_type -> capiscio.v1.ParseServerIdentityResponse + 23, // 25: capiscio.v1.MCPService.Health:output_type -> capiscio.v1.MCPHealthResponse + 21, // [21:26] is the sub-list for method output_type + 16, // [16:21] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_capiscio_v1_mcp_proto_init() } @@ -1621,7 +2192,7 @@ func file_capiscio_v1_mcp_proto_init() { (*EvaluateToolAccessRequest_BadgeJws)(nil), (*EvaluateToolAccessRequest_ApiKey)(nil), } - file_capiscio_v1_mcp_proto_msgTypes[7].OneofWrappers = []any{ + file_capiscio_v1_mcp_proto_msgTypes[13].OneofWrappers = []any{ (*ParseServerIdentityRequest_HttpHeaders)(nil), (*ParseServerIdentityRequest_JsonrpcMeta)(nil), } @@ -1631,7 +2202,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: 13, + NumMessages: 19, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/rpc/gen/capiscio/v1/mcp_grpc.pb.go b/pkg/rpc/gen/capiscio/v1/mcp_grpc.pb.go index 26ff29d..a884308 100644 --- a/pkg/rpc/gen/capiscio/v1/mcp_grpc.pb.go +++ b/pkg/rpc/gen/capiscio/v1/mcp_grpc.pb.go @@ -31,21 +31,29 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - MCPService_EvaluateToolAccess_FullMethodName = "/capiscio.v1.MCPService/EvaluateToolAccess" - MCPService_VerifyServerIdentity_FullMethodName = "/capiscio.v1.MCPService/VerifyServerIdentity" - MCPService_ParseServerIdentity_FullMethodName = "/capiscio.v1.MCPService/ParseServerIdentity" - MCPService_Health_FullMethodName = "/capiscio.v1.MCPService/Health" + MCPService_EvaluateToolAccess_FullMethodName = "/capiscio.v1.MCPService/EvaluateToolAccess" + MCPService_EvaluatePolicyDecision_FullMethodName = "/capiscio.v1.MCPService/EvaluatePolicyDecision" + MCPService_VerifyServerIdentity_FullMethodName = "/capiscio.v1.MCPService/VerifyServerIdentity" + MCPService_ParseServerIdentity_FullMethodName = "/capiscio.v1.MCPService/ParseServerIdentity" + MCPService_Health_FullMethodName = "/capiscio.v1.MCPService/Health" ) // MCPServiceClient is the client API for MCPService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// MCPService provides unified MCP security operations (RFC-006 + RFC-007) +// MCPService provides unified MCP security operations (RFC-005, RFC-006, RFC-007) type MCPServiceClient interface { // RFC-006: Evaluate tool access and emit evidence atomically // Single RPC returns both decision and evidence to avoid partial failures EvaluateToolAccess(ctx context.Context, in *EvaluateToolAccessRequest, opts ...grpc.CallOption) (*EvaluateToolAccessResponse, error) + // RFC-005: Centralized policy decision via PDP + // Go core owns decision logic, cache, break-glass, telemetry. + // SDK callers own obligation execution and response propagation. + // NEVER returns an RPC error for PDP unreachability — encodes the outcome + // in the response (ALLOW_OBSERVE + error_code) so SDKs don't need to + // distinguish transport errors from policy outcomes. + EvaluatePolicyDecision(ctx context.Context, in *PolicyDecisionRequest, opts ...grpc.CallOption) (*PolicyDecisionResponse, error) // RFC-007: Verify server identity from disclosed DID + badge VerifyServerIdentity(ctx context.Context, in *VerifyServerIdentityRequest, opts ...grpc.CallOption) (*VerifyServerIdentityResponse, error) // RFC-007: Extract server identity from transport headers/meta @@ -72,6 +80,16 @@ func (c *mCPServiceClient) EvaluateToolAccess(ctx context.Context, in *EvaluateT return out, nil } +func (c *mCPServiceClient) EvaluatePolicyDecision(ctx context.Context, in *PolicyDecisionRequest, opts ...grpc.CallOption) (*PolicyDecisionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PolicyDecisionResponse) + err := c.cc.Invoke(ctx, MCPService_EvaluatePolicyDecision_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *mCPServiceClient) VerifyServerIdentity(ctx context.Context, in *VerifyServerIdentityRequest, opts ...grpc.CallOption) (*VerifyServerIdentityResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VerifyServerIdentityResponse) @@ -106,11 +124,18 @@ func (c *mCPServiceClient) Health(ctx context.Context, in *MCPHealthRequest, opt // All implementations must embed UnimplementedMCPServiceServer // for forward compatibility. // -// MCPService provides unified MCP security operations (RFC-006 + RFC-007) +// MCPService provides unified MCP security operations (RFC-005, RFC-006, RFC-007) type MCPServiceServer interface { // RFC-006: Evaluate tool access and emit evidence atomically // Single RPC returns both decision and evidence to avoid partial failures EvaluateToolAccess(context.Context, *EvaluateToolAccessRequest) (*EvaluateToolAccessResponse, error) + // RFC-005: Centralized policy decision via PDP + // Go core owns decision logic, cache, break-glass, telemetry. + // SDK callers own obligation execution and response propagation. + // NEVER returns an RPC error for PDP unreachability — encodes the outcome + // in the response (ALLOW_OBSERVE + error_code) so SDKs don't need to + // distinguish transport errors from policy outcomes. + EvaluatePolicyDecision(context.Context, *PolicyDecisionRequest) (*PolicyDecisionResponse, error) // RFC-007: Verify server identity from disclosed DID + badge VerifyServerIdentity(context.Context, *VerifyServerIdentityRequest) (*VerifyServerIdentityResponse, error) // RFC-007: Extract server identity from transport headers/meta @@ -130,6 +155,9 @@ type UnimplementedMCPServiceServer struct{} func (UnimplementedMCPServiceServer) EvaluateToolAccess(context.Context, *EvaluateToolAccessRequest) (*EvaluateToolAccessResponse, error) { return nil, status.Error(codes.Unimplemented, "method EvaluateToolAccess not implemented") } +func (UnimplementedMCPServiceServer) EvaluatePolicyDecision(context.Context, *PolicyDecisionRequest) (*PolicyDecisionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EvaluatePolicyDecision not implemented") +} func (UnimplementedMCPServiceServer) VerifyServerIdentity(context.Context, *VerifyServerIdentityRequest) (*VerifyServerIdentityResponse, error) { return nil, status.Error(codes.Unimplemented, "method VerifyServerIdentity not implemented") } @@ -178,6 +206,24 @@ func _MCPService_EvaluateToolAccess_Handler(srv interface{}, ctx context.Context return interceptor(ctx, in, info, handler) } +func _MCPService_EvaluatePolicyDecision_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PolicyDecisionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MCPServiceServer).EvaluatePolicyDecision(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MCPService_EvaluatePolicyDecision_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MCPServiceServer).EvaluatePolicyDecision(ctx, req.(*PolicyDecisionRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _MCPService_VerifyServerIdentity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(VerifyServerIdentityRequest) if err := dec(in); err != nil { @@ -243,6 +289,10 @@ var MCPService_ServiceDesc = grpc.ServiceDesc{ MethodName: "EvaluateToolAccess", Handler: _MCPService_EvaluateToolAccess_Handler, }, + { + MethodName: "EvaluatePolicyDecision", + Handler: _MCPService_EvaluatePolicyDecision_Handler, + }, { MethodName: "VerifyServerIdentity", Handler: _MCPService_VerifyServerIdentity_Handler, diff --git a/proto/capiscio/v1/mcp.proto b/proto/capiscio/v1/mcp.proto index d59c6a9..ef15b3b 100644 --- a/proto/capiscio/v1/mcp.proto +++ b/proto/capiscio/v1/mcp.proto @@ -18,12 +18,20 @@ option go_package = "github.com/capiscio/capiscio-core/pkg/rpc/gen/capiscio/v1"; import "google/protobuf/timestamp.proto"; -// MCPService provides unified MCP security operations (RFC-006 + RFC-007) +// MCPService provides unified MCP security operations (RFC-005, RFC-006, RFC-007) service MCPService { // RFC-006: Evaluate tool access and emit evidence atomically // Single RPC returns both decision and evidence to avoid partial failures rpc EvaluateToolAccess(EvaluateToolAccessRequest) returns (EvaluateToolAccessResponse); + // RFC-005: Centralized policy decision via PDP + // Go core owns decision logic, cache, break-glass, telemetry. + // SDK callers own obligation execution and response propagation. + // NEVER returns an RPC error for PDP unreachability — encodes the outcome + // in the response (ALLOW_OBSERVE + error_code) so SDKs don't need to + // distinguish transport errors from policy outcomes. + rpc EvaluatePolicyDecision(PolicyDecisionRequest) returns (PolicyDecisionResponse); + // RFC-007: Verify server identity from disclosed DID + badge rpc VerifyServerIdentity(VerifyServerIdentityRequest) returns (VerifyServerIdentityResponse); @@ -129,6 +137,129 @@ message MCPObligation { string params_json = 2; // opaque JSON } +// ============================================================================ +// RFC-005: Centralized Policy Decision +// ============================================================================ + +// Request for centralized policy decision. +// The Go core handles: PDP query, decision cache, break-glass override, +// enforcement mode logic, and telemetry emission. +// The SDK caller handles: obligation execution, response propagation, +// and surface-specific error handling. +message PolicyDecisionRequest { + // Subject identity (from badge verification, already completed by SDK) + PolicySubject subject = 1; + + // What is being attempted + PolicyAction action = 2; + + // Target resource + PolicyResource resource = 3; + + // PDP and PEP configuration + PolicyConfig config = 4; + + // Optional break-glass override token (compact JWS, EdDSA) + string breakglass_token = 5; +} + +// Subject attributes for policy evaluation. +// SDK extracts these from the verified badge before calling this RPC. +message PolicySubject { + string did = 1; // Badge sub (agent DID) + string badge_jti = 2; // Badge jti + string ial = 3; // Badge ial + string trust_level = 4; // Badge vc.credentialSubject.level ("1", "2", "3") + int64 badge_exp = 5; // Badge exp (Unix seconds) — bounds cache TTL +} + +// Action attributes for policy evaluation. +message PolicyAction { + string operation = 1; // tool name, HTTP method+route, etc. + string capability_class = 2; // empty in badge-only mode (RFC-008) +} + +// Resource attributes for policy evaluation. +message PolicyResource { + string identifier = 1; // target resource URI +} + +// PEP-level configuration for the policy decision. +message PolicyConfig { + // PDP endpoint URL. If empty, RPC returns ALLOW (badge-only mode). + string pdp_endpoint = 1; + + // PDP query timeout in milliseconds. 0 or negative → 500ms default. + int32 pdp_timeout_ms = 2; + + // Enforcement mode: EM-OBSERVE, EM-GUARD, EM-DELEGATE, EM-STRICT. + // Empty → EM-OBSERVE. + string enforcement_mode = 3; + + // PEP identifier (included in PDP requests for audit). + string pep_id = 4; + + // Workspace identifier (included in PDP requests). + string workspace = 5; + + // Break-glass Ed25519 public key (raw 32 bytes). + // Must be separate from CA badge-signing key. + // Server-side configuration provides the key material directly; + // no filesystem paths cross the RPC boundary. + bytes breakglass_public_key = 6; +} + +// Response from centralized policy decision. +// This is ALWAYS a successful RPC response — PDP unreachability is encoded +// in the response fields, never as a gRPC error. SDKs should not need to +// distinguish transport errors from policy outcomes. +message PolicyDecisionResponse { + // Policy decision: "ALLOW", "DENY", or "ALLOW_OBSERVE". + // ALLOW_OBSERVE indicates PDP was unreachable in EM-OBSERVE mode. + string decision = 1; + + // Globally unique decision ID from the PDP. + // Synthetic IDs (e.g., "pdp-unavailable", "breakglass-override", "cache-hit") + // are used when the PDP was not consulted. + string decision_id = 2; + + // Human-readable reason (populated on DENY or when PDP provides one). + string reason = 3; + + // Cache TTL in seconds from PDP response. 0 if not cacheable. + int32 ttl = 4; + + // Obligations the SDK must execute. Obligation *decision* and *registry + // enforcement* is done by the Go core per the EM matrix. Only obligations + // that the core determined should proceed are returned here. + // For EM-OBSERVE: all obligations are returned (for logging). + // For EM-STRICT: only if all known, all succeeded in core pre-check. + repeated MCPObligation obligations = 5; + + // Enforcement mode that was applied for this decision. + string enforcement_mode = 6; + + // Whether this decision came from cache (vs live PDP query). + bool cache_hit = 7; + + // Whether a break-glass override was applied. + bool breakglass_override = 8; + + // Break-glass token JTI (for audit trail, only set when override applied). + string breakglass_jti = 9; + + // Error code when PDP could not be consulted. + // Empty string when PDP responded normally. + // Values: "pdp_unavailable", "pdp_timeout", "pdp_invalid_response". + string error_code = 10; + + // PDP query latency in milliseconds (0 if cache hit or PDP not consulted). + int64 pdp_latency_ms = 11; + + // Transaction ID (UUID v7) assigned to this decision. + string txn_id = 12; +} + // Access decision enum enum MCPDecision { MCP_DECISION_UNSPECIFIED = 0;