From c56bc5d90bfcc190f0f2c8097427b082b1f2d3e3 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 15 Apr 2026 10:37:00 +0100 Subject: [PATCH] Store serverName on Cedar Authorizer The AuthorizerFactory interface already passes serverName to CreateAuthorizer, but the Cedar implementation discarded it. Store it on the Authorizer struct so downstream enterprise features (#4769, behavior is identical to today. The serverName becomes the MCP parent on resource entities (added by policies like "resource in MCP::". Per the authorization RFC, this prevents a deny rule compiled from one server's policy from silently affecting same-named tools on other servers when the enterprise controller merges policies into a single set. E2E tested in a Kind cluster with real Okta tokens. The key test deploys two MCPServers that share a single Cedar ConfigMap -- the same setup the enterprise controller will produce when it compiles policies from multiple CRDs into one set. Shared Cedar policy set: permit(principal in THVGroup::"engineering", action, resource in MCP::""); permit(principal in THVGroup::"engineering", action, resource in MCP::""); forbid(principal, action == Action::"call_tool", resource == Tool::"echo") when { resource in MCP::"" }; Okta JWT: { "groups": ["Everyone", "engineering"], "sub": "jakub@stacklok.com" } The same user calling the same tool ("echo") gets allowed on server-a and 403'd on server-b. The only difference is the serverName stored on each authorizer, which determines the MCP parent on the resource entity. Cedar's diagnostic log confirms the forbid fires only where scoped and does not bleed across servers. Closes #4764 Co-Authored-By: Claude Opus 4.6 --- .../cedar/annotations_integration_test.go | 2 +- .../cedar/annotations_override_test.go | 2 +- pkg/authz/authorizers/cedar/core.go | 17 ++++++-- pkg/authz/authorizers/cedar/core_test.go | 43 +++++++++++++------ pkg/authz/integration_test.go | 4 +- pkg/authz/middleware_test.go | 12 +++--- pkg/authz/response_filter_test.go | 12 +++--- pkg/authz/tool_filter_test.go | 6 +-- 8 files changed, 62 insertions(+), 36 deletions(-) diff --git a/pkg/authz/authorizers/cedar/annotations_integration_test.go b/pkg/authz/authorizers/cedar/annotations_integration_test.go index c36a90f552..e7cb9fb81a 100644 --- a/pkg/authz/authorizers/cedar/annotations_integration_test.go +++ b/pkg/authz/authorizers/cedar/annotations_integration_test.go @@ -98,7 +98,7 @@ func TestAuthorizeWithToolAnnotations(t *testing.T) { authzr, err := NewCedarAuthorizer(ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Build context with identity diff --git a/pkg/authz/authorizers/cedar/annotations_override_test.go b/pkg/authz/authorizers/cedar/annotations_override_test.go index b6161c1737..6bc7c6fb92 100644 --- a/pkg/authz/authorizers/cedar/annotations_override_test.go +++ b/pkg/authz/authorizers/cedar/annotations_override_test.go @@ -128,7 +128,7 @@ func TestAnnotationAttributesCannotOverrideStandardAttributes(t *testing.T) { authzr, err := NewCedarAuthorizer(ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") ctx := t.Context() diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index 4c173085b4..122b3e6e20 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -119,7 +119,7 @@ func (*Factory) ValidateConfig(rawConfig json.RawMessage) error { // CreateAuthorizer creates a Cedar Authorizer from the configuration. // It receives the full raw config and extracts the Cedar-specific portion. -func (*Factory) CreateAuthorizer(rawConfig json.RawMessage, _ string) (authorizers.Authorizer, error) { +func (*Factory) CreateAuthorizer(rawConfig json.RawMessage, serverName string) (authorizers.Authorizer, error) { var config Config if err := json.Unmarshal(rawConfig, &config); err != nil { return nil, fmt.Errorf("failed to parse configuration: %w", err) @@ -129,7 +129,7 @@ func (*Factory) CreateAuthorizer(rawConfig json.RawMessage, _ string) (authorize return nil, fmt.Errorf("cedar configuration is required (missing 'cedar' field)") } - return NewCedarAuthorizer(*config.Options) + return NewCedarAuthorizer(*config.Options, serverName) } // Common errors for Cedar authorization @@ -167,6 +167,12 @@ type Authorizer struct { // roleClaimName is the JWT claim key that contains role membership. // When empty, no role extraction is performed (backward compatible). roleClaimName string + // serverName is the identity of the MCP server this authorizer is scoped to. + // Used by downstream enterprise features for server-scoped Cedar policies + // (e.g. resource in MCP::""). When empty (standalone Cedar usage + // with no enterprise controller), the authorizer behaves identically to + // the unscoped case. + serverName string // claimKeyLog rate-limits the diagnostic log of resolved JWT claim keys // so it emits at most once per 30 seconds instead of once per authorization check. claimKeyLog *syncutil.AtMost @@ -201,7 +207,11 @@ type ConfigOptions struct { } // NewCedarAuthorizer creates a new Cedar authorizer. -func NewCedarAuthorizer(options ConfigOptions) (authorizers.Authorizer, error) { +// serverName is a runtime-injected value (not user-authored config) that +// identifies which MCP server this authorizer is scoped to. +// If a second runtime-injected value is needed, bundle both into a +// RuntimeContext struct to keep the factory interface stable. +func NewCedarAuthorizer(options ConfigOptions, serverName string) (authorizers.Authorizer, error) { authorizer := &Authorizer{ policySet: cedar.NewPolicySet(), entities: cedar.EntityMap{}, @@ -209,6 +219,7 @@ func NewCedarAuthorizer(options ConfigOptions) (authorizers.Authorizer, error) { primaryUpstreamProvider: options.PrimaryUpstreamProvider, groupClaimName: options.GroupClaimName, roleClaimName: options.RoleClaimName, + serverName: serverName, claimKeyLog: syncutil.NewAtMost(30 * time.Second), } diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 92976cbff6..416f51da8d 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -37,9 +37,11 @@ func TestNewCedarAuthorizer(t *testing.T) { policies []string entitiesJSON string roleClaimName string + serverName string expectError bool errorType error wantRoleClaimName string + wantServerName string }{ { name: "Valid policy and empty entities", @@ -94,6 +96,14 @@ func TestNewCedarAuthorizer(t *testing.T) { expectError: false, wantRoleClaimName: "https://example.com/roles", }, + { + name: "Stores server name", + policies: []string{`permit(principal, action, resource);`}, + entitiesJSON: `[]`, + serverName: "my-mcp-server", + expectError: false, + wantServerName: "my-mcp-server", + }, } // Run test cases @@ -105,7 +115,7 @@ func TestNewCedarAuthorizer(t *testing.T) { Policies: tc.policies, EntitiesJSON: tc.entitiesJSON, RoleClaimName: tc.roleClaimName, - }) + }, tc.serverName) // Check error expectations if tc.expectError { @@ -121,6 +131,7 @@ func TestNewCedarAuthorizer(t *testing.T) { cedarAuthz, ok := authorizer.(*Authorizer) require.True(t, ok) assert.Equal(t, tc.wantRoleClaimName, cedarAuthz.roleClaimName) + assert.Equal(t, tc.wantServerName, cedarAuthz.serverName) } }) } @@ -351,7 +362,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{tc.policy}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Create a context with JWT claims @@ -376,7 +387,7 @@ func TestAuthorizeWithJWTClaimsErrors(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Test cases @@ -675,6 +686,10 @@ func TestFactoryCreateAuthorizer(t *testing.T) { require.NoError(t, err) require.NotNil(t, authorizer) + + cedarAuthz, ok := authorizer.(*Authorizer) + require.True(t, ok) + assert.Equal(t, "testServer", cedarAuthz.serverName) }) } } @@ -687,7 +702,7 @@ func TestUpdatePolicies(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type to access UpdatePolicies @@ -748,7 +763,7 @@ func TestUpdateEntities(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type to access UpdateEntities @@ -799,7 +814,7 @@ func TestEntityOperations(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type to access entity methods @@ -839,7 +854,7 @@ func TestGetEntityNotFound(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -863,7 +878,7 @@ func TestIsAuthorizedErrors(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{`permit(principal, action, resource);`}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -958,7 +973,7 @@ func TestIsAuthorizedWithEntities(t *testing.T) { ); `}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) // Cast to concrete type @@ -1097,7 +1112,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, - }) + }, "") require.NoError(t, err) upstreamToken := makeUnsignedJWT(jwt.MapClaims{ @@ -1242,7 +1257,7 @@ func TestAuthorizeWithJWTClaims_GroupMembership(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{policy}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) tests := []struct { @@ -1313,7 +1328,7 @@ func TestAuthorizeWithJWTClaims_DoesNotMutateIdentity(t *testing.T) { authorizer, err := NewCedarAuthorizer(ConfigOptions{ Policies: []string{policy}, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) identity := &auth.Identity{ @@ -1361,7 +1376,7 @@ func TestAuthorizeWithJWTClaims_CustomGroupClaimName(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, GroupClaimName: "https://example.com/groups", - }) + }, "") require.NoError(t, err) // The custom claim holds "platform"; the well-known "groups" key holds other groups. @@ -1410,7 +1425,7 @@ func TestAuthorizeWithJWTClaims_UpstreamProviderWithGroups(t *testing.T) { Policies: []string{policy}, EntitiesJSON: `[]`, PrimaryUpstreamProvider: providerName, - }) + }, "") require.NoError(t, err) tests := []struct { diff --git a/pkg/authz/integration_test.go b/pkg/authz/integration_test.go index 990a298b45..4c17bd6dc3 100644 --- a/pkg/authz/integration_test.go +++ b/pkg/authz/integration_test.go @@ -48,7 +48,7 @@ func TestIntegrationListFiltering(t *testing.T) { `permit(principal, action == Action::"read_resource", resource) when { principal.claim_role == "admin" };`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") testCases := []struct { @@ -335,7 +335,7 @@ func TestIntegrationNonListOperations(t *testing.T) { `permit(principal, action == Action::"call_tool", resource) when { principal.claim_role == "admin" };`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") testCases := []struct { diff --git a/pkg/authz/middleware_test.go b/pkg/authz/middleware_test.go index 2def6ff1a0..2c3a815561 100644 --- a/pkg/authz/middleware_test.go +++ b/pkg/authz/middleware_test.go @@ -61,7 +61,7 @@ func TestMiddleware(t *testing.T) { `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Test cases @@ -443,7 +443,7 @@ func TestMiddlewareWithGETRequest(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Create a handler that records if it was called @@ -808,7 +808,7 @@ func TestMiddlewareToolsListTestkit(t *testing.T) { cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }, + }, "", ) require.NoError(t, err, "Failed to create Cedar authorizer") @@ -978,7 +978,7 @@ func TestMiddlewareToolsCallTestkit(t *testing.T) { cedar.ConfigOptions{ Policies: tc.policies, EntitiesJSON: `[]`, - }, + }, "", ) require.NoError(t, err, "Failed to create Cedar authorizer") @@ -1049,7 +1049,7 @@ func TestMiddlewareOptimizerMetaTools(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"allowed_backend");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) passThroughTools := map[string]struct{}{ @@ -1150,7 +1150,7 @@ func TestMiddlewareOptimizerCallToolJSONRoundTrip(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"backend_fetch");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) passThroughTools := map[string]struct{}{ diff --git a/pkg/authz/response_filter_test.go b/pkg/authz/response_filter_test.go index 450068e184..f36c70e772 100644 --- a/pkg/authz/response_filter_test.go +++ b/pkg/authz/response_filter_test.go @@ -88,7 +88,7 @@ func TestFindToolResponseFilter(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ @@ -239,7 +239,7 @@ func TestResponseFilteringWriter(t *testing.T) { `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") testCases := []struct { @@ -432,7 +432,7 @@ func TestResponseFilteringWriter_NonListOperations(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Test that non-list operations pass through unchanged @@ -481,7 +481,7 @@ func TestResponseFilteringWriter_ErrorResponse(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Create an error response @@ -534,7 +534,7 @@ func TestResponseFilteringWriter_ContentLengthMismatch(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err, "Failed to create Cedar authorizer") // Build the backend response: a tools/list result with 3 tools. @@ -727,7 +727,7 @@ func TestOptimizerPassThroughToolsInResponseFilter(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: "[]", - }) + }, "") require.NoError(t, err) // Build a tools/list response as the optimizer would produce it: diff --git a/pkg/authz/tool_filter_test.go b/pkg/authz/tool_filter_test.go index 061eda93ef..0043de1526 100644 --- a/pkg/authz/tool_filter_test.go +++ b/pkg/authz/tool_filter_test.go @@ -181,7 +181,7 @@ func TestFilterToolsByPolicy_WithCedarAuthorizer(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) t.Run("keeps only permitted tool", func(t *testing.T) { @@ -222,7 +222,7 @@ func TestAuthorizeToolCall_WithCedarAuthorizer(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) t.Run("permits authorized tool", func(t *testing.T) { @@ -252,7 +252,7 @@ func TestAuthorizeToolCall_WithArguments(t *testing.T) { `permit(principal, action == Action::"call_tool", resource == Tool::"deploy") when { context.arg_mode == "safe" };`, }, EntitiesJSON: `[]`, - }) + }, "") require.NoError(t, err) t.Run("permits when arguments satisfy policy", func(t *testing.T) {