Skip to content

Commit f3c9f24

Browse files
jhrozekclaude
andauthored
Set MCP parent on resource entities for server-scoped policies (#4965)
When serverName is configured on the Cedar authorizer, resource entities (Tool, Prompt, Resource) need an MCP parent so that server-scoped Cedar policies like `resource in MCP::"github"` evaluate correctly. Without this parent, those policies silently match nothing and deny all requests. Add serverName parameter to CreateEntitiesForRequest and build an MCP::"<serverName>" parent UID when non-empty. Thread a.serverName through all four authorize* methods in core.go. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ee39caf commit f3c9f24

4 files changed

Lines changed: 181 additions & 6 deletions

File tree

pkg/authz/authorizers/cedar/core.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,9 @@ func (a *Authorizer) authorizeToolCall(
567567
})
568568

569569
// Create Cedar entities
570-
entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups)
570+
entities, err := a.entityFactory.CreateEntitiesForRequest(
571+
principal, action, resource, claimsMap, attributes, groups, a.serverName,
572+
)
571573
if err != nil {
572574
return false, fmt.Errorf("failed to create Cedar entities: %w", err)
573575
}
@@ -604,7 +606,9 @@ func (a *Authorizer) authorizePromptGet(
604606
}, attrsMap)
605607

606608
// Create Cedar entities
607-
entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups)
609+
entities, err := a.entityFactory.CreateEntitiesForRequest(
610+
principal, action, resource, claimsMap, attributes, groups, a.serverName,
611+
)
608612
if err != nil {
609613
return false, fmt.Errorf("failed to create Cedar entities: %w", err)
610614
}
@@ -644,7 +648,9 @@ func (a *Authorizer) authorizeResourceRead(
644648
}, attrsMap)
645649

646650
// Create Cedar entities
647-
entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups)
651+
entities, err := a.entityFactory.CreateEntitiesForRequest(
652+
principal, action, resource, claimsMap, attributes, groups, a.serverName,
653+
)
648654
if err != nil {
649655
return false, fmt.Errorf("failed to create Cedar entities: %w", err)
650656
}
@@ -682,7 +688,9 @@ func (a *Authorizer) authorizeFeatureList(
682688
}, attrsMap)
683689

684690
// Create Cedar entities
685-
entities, err := a.entityFactory.CreateEntitiesForRequest(principal, action, resource, claimsMap, attributes, groups)
691+
entities, err := a.entityFactory.CreateEntitiesForRequest(
692+
principal, action, resource, claimsMap, attributes, groups, a.serverName,
693+
)
686694
if err != nil {
687695
return false, fmt.Errorf("failed to create Cedar entities: %w", err)
688696
}

pkg/authz/authorizers/cedar/core_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,7 @@ func TestIsAuthorizedWithEntities(t *testing.T) {
10531053
map[string]interface{}{"name": "Test User"},
10541054
map[string]interface{}{"name": "weather"},
10551055
nil,
1056+
"",
10561057
)
10571058
require.NoError(t, err)
10581059

@@ -1068,6 +1069,67 @@ func TestIsAuthorizedWithEntities(t *testing.T) {
10681069
assert.True(t, authorized)
10691070
}
10701071

1072+
// TestServerScopedPolicyWithMCPParent verifies end-to-end Cedar evaluation
1073+
// with a server-scoped policy. When the authorizer has a serverName, resource
1074+
// entities get an MCP parent and `resource in MCP::"<server>"` matches.
1075+
// When serverName is empty, the same policy denies because there is no parent.
1076+
func TestServerScopedPolicyWithMCPParent(t *testing.T) {
1077+
t.Parallel()
1078+
1079+
policy := `permit(
1080+
principal,
1081+
action == Action::"call_tool",
1082+
resource in MCP::"test-server"
1083+
);`
1084+
1085+
// The MCP entity must be present in the entity store for Cedar's `in`
1086+
// operator to traverse the parent chain. In production this comes from
1087+
// entities_json managed by the enterprise controller.
1088+
mcpEntity := `[{"uid":{"type":"MCP","id":"test-server"},"parents":[],"attrs":{}}]`
1089+
1090+
tests := []struct {
1091+
name string
1092+
serverName string
1093+
wantAllow bool
1094+
}{
1095+
{
1096+
name: "serverName_matches_policy_permits",
1097+
serverName: "test-server",
1098+
wantAllow: true,
1099+
},
1100+
{
1101+
name: "empty_serverName_policy_denies",
1102+
serverName: "",
1103+
wantAllow: false,
1104+
},
1105+
{
1106+
name: "wrong_serverName_policy_denies",
1107+
serverName: "other-server",
1108+
wantAllow: false,
1109+
},
1110+
}
1111+
1112+
for _, tt := range tests {
1113+
t.Run(tt.name, func(t *testing.T) {
1114+
t.Parallel()
1115+
1116+
authorizer, err := NewCedarAuthorizer(ConfigOptions{
1117+
Policies: []string{policy},
1118+
EntitiesJSON: mcpEntity,
1119+
}, tt.serverName)
1120+
require.NoError(t, err)
1121+
1122+
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "testuser", Claims: map[string]interface{}{"sub": "testuser"}}}
1123+
ctx := auth.WithIdentity(context.Background(), identity)
1124+
1125+
authorized, err := authorizer.AuthorizeWithJWTClaims(ctx, authorizers.MCPFeatureTool, authorizers.MCPOperationCall, "weather", nil)
1126+
assert.NoError(t, err)
1127+
assert.Equal(t, tt.wantAllow, authorized,
1128+
"serverName=%q: expected allow=%v", tt.serverName, tt.wantAllow)
1129+
})
1130+
}
1131+
}
1132+
10711133
// TestParseUpstreamJWTClaims tests the parseUpstreamJWTClaims helper.
10721134
func TestParseUpstreamJWTClaims(t *testing.T) {
10731135
t.Parallel()

pkg/authz/authorizers/cedar/entity.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,17 @@ func (*EntityFactory) CreateResourceEntity(
106106
// Cedar's `in` operator works for group-based policies. Unlike the pre-refactor
107107
// code, no separate THVGroup entities are inserted into the entity map — those
108108
// must come from entities_json to preserve the role hierarchy.
109+
//
110+
// When serverName is non-empty, resource entities include an MCP parent UID so
111+
// that server-scoped Cedar policies (e.g. `resource in MCP::"github"`) evaluate
112+
// correctly via Cedar's `in` operator. When serverName is empty, resource
113+
// entities have no parents, preserving backward compatibility.
109114
func (f *EntityFactory) CreateEntitiesForRequest(
110115
principal, action, resource string,
111116
claimsMap map[string]interface{},
112117
attributes map[string]interface{},
113118
groups []string,
119+
serverName string,
114120
) (cedar.EntityMap, error) {
115121
// Parse principal, action, and resource
116122
principalType, principalID, err := parseCedarEntityID(principal)
@@ -149,8 +155,15 @@ func (f *EntityFactory) CreateEntitiesForRequest(
149155
actionUID, actionEntity := f.CreateActionEntity(actionType, actionID, nil)
150156
entities[actionUID] = actionEntity
151157

158+
// Build MCP parent for resource entity when serverName is set so that
159+
// server-scoped policies (e.g. resource in MCP::"github") can match.
160+
var resourceParents []cedar.EntityUID
161+
if serverName != "" {
162+
resourceParents = append(resourceParents, cedar.NewEntityUID("MCP", cedar.String(serverName)))
163+
}
164+
152165
// Create resource entity
153-
resourceUID, resourceEntity := f.CreateResourceEntity(resourceType, resourceID, attributes)
166+
resourceUID, resourceEntity := f.CreateResourceEntity(resourceType, resourceID, attributes, resourceParents...)
154167
entities[resourceUID] = resourceEntity
155168

156169
return entities, nil

pkg/authz/authorizers/cedar/entity_test.go

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ func TestCreateEntitiesForRequest_GroupsAsParents(t *testing.T) {
270270
map[string]interface{}{"sub": "user1"},
271271
map[string]interface{}{"name": "weather"},
272272
tt.groups,
273+
"",
273274
)
274275
require.NoError(t, err)
275276
require.NotNil(t, entities)
@@ -387,7 +388,7 @@ func TestCreateCedarEntities(t *testing.T) {
387388
factory := NewEntityFactory()
388389

389390
// Create Cedar entities (no groups for these test cases)
390-
entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil)
391+
entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "")
391392

392393
// Check error expectations
393394
if tc.expectErr {
@@ -463,3 +464,94 @@ func TestCreateCedarEntities(t *testing.T) {
463464
})
464465
}
465466
}
467+
468+
// TestCreateEntitiesForRequest_MCPParent verifies that CreateEntitiesForRequest
469+
// sets an MCP parent UID on the resource entity when serverName is non-empty,
470+
// and leaves the resource parentless when serverName is empty.
471+
func TestCreateEntitiesForRequest_MCPParent(t *testing.T) {
472+
t.Parallel()
473+
474+
factory := NewEntityFactory()
475+
476+
tests := []struct {
477+
name string
478+
resource string
479+
serverName string
480+
wantParentCount int
481+
wantMCPParentID string
482+
}{
483+
{
484+
name: "empty_serverName_no_parent",
485+
resource: "Tool::weather",
486+
serverName: "",
487+
wantParentCount: 0,
488+
},
489+
{
490+
name: "serverName_sets_MCP_parent_on_Tool",
491+
resource: "Tool::weather",
492+
serverName: "github",
493+
wantParentCount: 1,
494+
wantMCPParentID: "github",
495+
},
496+
{
497+
name: "serverName_sets_MCP_parent_on_Prompt",
498+
resource: "Prompt::greeting",
499+
serverName: "github",
500+
wantParentCount: 1,
501+
wantMCPParentID: "github",
502+
},
503+
{
504+
name: "serverName_sets_MCP_parent_on_Resource",
505+
resource: "Resource::readme",
506+
serverName: "github",
507+
wantParentCount: 1,
508+
wantMCPParentID: "github",
509+
},
510+
{
511+
name: "serverName_with_special_characters",
512+
resource: "Tool::weather",
513+
serverName: "my-server.example.com",
514+
wantParentCount: 1,
515+
wantMCPParentID: "my-server.example.com",
516+
},
517+
}
518+
519+
for _, tt := range tests {
520+
t.Run(tt.name, func(t *testing.T) {
521+
t.Parallel()
522+
523+
entities, err := factory.CreateEntitiesForRequest(
524+
"Client::user1",
525+
"Action::call_tool",
526+
tt.resource,
527+
map[string]interface{}{"sub": "user1"},
528+
map[string]interface{}{},
529+
nil,
530+
tt.serverName,
531+
)
532+
require.NoError(t, err)
533+
require.NotNil(t, entities)
534+
535+
// Find the resource entity by scanning for a non-Client, non-Action UID.
536+
var resourceEntity cedar.Entity
537+
var found bool
538+
for uid, ent := range entities {
539+
if string(uid.Type) != "Client" && string(uid.Type) != "Action" {
540+
resourceEntity = ent
541+
found = true
542+
break
543+
}
544+
}
545+
require.True(t, found, "resource entity not found in map")
546+
547+
assert.Equal(t, tt.wantParentCount, resourceEntity.Parents.Len(),
548+
"unexpected number of parents on resource entity")
549+
550+
if tt.wantMCPParentID != "" {
551+
mcpUID := cedar.NewEntityUID("MCP", cedar.String(tt.wantMCPParentID))
552+
assert.True(t, resourceEntity.Parents.Contains(mcpUID),
553+
"expected MCP::%q in resource.Parents", tt.wantMCPParentID)
554+
}
555+
})
556+
}
557+
}

0 commit comments

Comments
 (0)