From 13fa0c6671b5b5aac4133c10be15fc6a8a4464ff Mon Sep 17 00:00:00 2001 From: Sanskarzz Date: Wed, 2 Sep 2026 15:45:39 +0530 Subject: [PATCH] Add backend-scoped vMCP authorization --- docs/arch/10-virtual-mcp-architecture.md | 9 +++ docs/authz.md | 29 ++++++++ pkg/authz/authorizers/cedar/core.go | 14 +++- pkg/authz/authorizers/cedar/core_test.go | 46 ++++++++++++ pkg/authz/authorizers/cedar/entity.go | 39 ++++++++-- pkg/authz/authorizers/cedar/entity_test.go | 64 +++++++++++++++- pkg/authz/authorizers/resource_metadata.go | 33 +++++++++ .../authorizers/resource_metadata_test.go | 33 +++++++++ pkg/vmcp/core/admission.go | 3 +- pkg/vmcp/core/admission_test.go | 74 +++++++++++++++---- 10 files changed, 320 insertions(+), 24 deletions(-) create mode 100644 pkg/authz/authorizers/resource_metadata.go create mode 100644 pkg/authz/authorizers/resource_metadata_test.go diff --git a/docs/arch/10-virtual-mcp-architecture.md b/docs/arch/10-virtual-mcp-architecture.md index df84a8dd43..5d2565d0ee 100644 --- a/docs/arch/10-virtual-mcp-architecture.md +++ b/docs/arch/10-virtual-mcp-architecture.md @@ -1052,6 +1052,15 @@ the list side (`ListTools`/`ListResources`/`ListPrompts` filter the advertised s the call side (`CallTool`/`ReadResource`/`GetPrompt` deny before dispatch), closing the "list says yes / call says no" gap. +For tool decisions, admission carries the advertised capability's trusted logical +`BackendID` into Cedar. The request's `Tool` entity remains a child of the vMCP's +`MCP` entity and also becomes a child of `Backend::`. The Backend entity +is materialized in the request entity map so `resource in Backend::"..."` policies +work with dynamically discovered backends. Tool names and arguments are never used +to infer backend membership. Composite tools with no single origin have no Backend +parent. If `entities_json` configures the same Backend with attributes or a parent +hierarchy, that configured entity is preserved. + Because the SDK maps a call-side deny to a tool result, a raw denied `tools/call` would otherwise return **HTTP 200** (either the SDK's `-32602 "not found"` for a list-filtered tool, or a `200 + IsError` tool result for an argument-gated deny). To make a denial a diff --git a/docs/authz.md b/docs/authz.md index 45a2bba0ad..6fa79e138e 100644 --- a/docs/authz.md +++ b/docs/authz.md @@ -215,6 +215,35 @@ permit(principal, action == Action::"call_tool", resource == Tool::"weather"); This policy allows any client to call the weather tool. +##### Allow tools from a specific vMCP backend + +For tools advertised by a Virtual MCP Server, Cedar receives the logical +originating backend as a second resource parent: + +```text +Tool::"search" + -> MCP::"main-vmcp" + -> Backend::"github-mcp" +``` + +This allows every tool from one backend without relying on the advertised tool +name or its conflict-resolution prefix: + +```plain +permit( + principal, + action == Action::"call_tool", + resource in Backend::"github-mcp" +); +``` + +The Backend entity ID is the tool's logical vMCP `BackendID`, not a network +address. ToolHive obtains it from the aggregated capability and uses the same +value for list filtering and call authorization. A direct Backend policy does +not require an entry in `entities_json`; ToolHive materializes the Backend entity +for the request. A configured Backend entity with the same ID is retained when +it supplies attributes or parents for a transitive hierarchy. + ##### Allow a specific prompt ```plain diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index e370653628..d3dc5b4388 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -527,6 +527,15 @@ func (a *Authorizer) IsAuthorized( mergedEntities[k] = v } for k, v := range entities[0] { + // A request materializes a minimal Backend entity so direct + // resource-in-Backend policies work without static configuration. + // Preserve a configured Backend with the same UID because it may + // carry attributes or parents for transitive backend hierarchies. + if k.Type == EntityTypeBackend { + if _, configured := mergedEntities[k]; configured { + continue + } + } mergedEntities[k] = v } @@ -1015,10 +1024,11 @@ func (a *Authorizer) authorizeToolCall( "operation": "call", "feature": "tool", }) + resourceMetadata, _ := authorizers.ResourceMetadataFromContext(ctx) // Create Cedar entities - entities, err := a.entityFactory.CreateEntitiesForRequest( - principal, action, resource, claimsMap, attributes, groups, a.serverName, + entities, err := a.entityFactory.createEntitiesForRequest( + principal, action, resource, claimsMap, attributes, groups, a.serverName, resourceMetadata.BackendID, ) if err != nil { return false, fmt.Errorf("failed to create Cedar entities: %w", err) diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index bca48b8788..30e6ac5f98 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -2156,6 +2156,52 @@ func TestAuthorizeWithJWTClaims_TransitiveHierarchyPreserved(t *testing.T) { "transitive hierarchy THVGroup→THVRole from entities_json must survive entity merge") } +func TestAuthorizeWithJWTClaims_BackendHierarchyPreserved(t *testing.T) { + t.Parallel() + + policy := `permit( + principal, + action == Action::"call_tool", + resource in BackendGroup::"production-approved" + );` + entitiesJSON := `[ + { + "uid": {"type": "Backend", "id": "github-mcp"}, + "attrs": {"environment": "production"}, + "parents": [{"type": "BackendGroup", "id": "production-approved"}] + }, + { + "uid": {"type": "BackendGroup", "id": "production-approved"}, + "attrs": {}, + "parents": [] + } + ]` + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: entitiesJSON, + }, "main-vmcp") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]any{"sub": "user1"}, + }} + ctx := auth.WithIdentity(context.Background(), identity) + ctx = authorizers.WithResourceMetadata(ctx, authorizers.ResourceMetadata{BackendID: "github-mcp"}) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + ctx, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "renamed-search", + nil, + ) + require.NoError(t, err) + assert.True(t, authorized, + "request Backend entity must not overwrite the configured transitive hierarchy") +} + // TestAuthorizeWithJWTClaims_DoesNotMutateIdentity verifies that // AuthorizeWithJWTClaims does not mutate the Identity stored in context. // The Identity contract (see auth.Identity) requires that the struct MUST NOT diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index bea4e06c1b..2c842c9869 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -17,11 +17,16 @@ import ( // maxSchemaDepth in pkg/vmcp/composer/elicitation_handler.go for consistency. const maxClaimNestingDepth = 10 -// EntityTypeTHVGroup is the default Cedar entity type representing group membership. -// It is used when ConfigOptions.GroupEntityType is empty. Principals are added as -// children of group entities so that Cedar's `in` operator can evaluate -// group-based policies (e.g. `principal in THVGroup::"engineering"`). -const EntityTypeTHVGroup cedar.EntityType = "THVGroup" +const ( + // EntityTypeTHVGroup is the default Cedar entity type representing group membership. + // It is used when ConfigOptions.GroupEntityType is empty. Principals are added as + // children of group entities so that Cedar's `in` operator can evaluate + // group-based policies (e.g. `principal in THVGroup::"engineering"`). + EntityTypeTHVGroup cedar.EntityType = "THVGroup" + // EntityTypeBackend represents a logical vMCP backend. Tools are added as + // children of their originating backend for backend-scoped policies. + EntityTypeBackend cedar.EntityType = "Backend" +) // EntityFactory creates Cedar entities for authorization. type EntityFactory struct { @@ -137,6 +142,20 @@ func (f *EntityFactory) CreateEntitiesForRequest( attributes map[string]interface{}, groups []string, serverName string, +) (cedar.EntityMap, error) { + return f.createEntitiesForRequest( + principal, action, resource, claimsMap, attributes, groups, serverName, "") +} + +// createEntitiesForRequest adds the request's principal, action, and resource +// entities. A non-empty backendID also makes the resource a child of a +// materialized Backend entity so Cedar can traverse backend membership. +func (f *EntityFactory) createEntitiesForRequest( + principal, action, resource string, + claimsMap map[string]interface{}, + attributes map[string]interface{}, + groups []string, + serverName, backendID string, ) (cedar.EntityMap, error) { // Parse principal, action, and resource principalType, principalID, err := parseCedarEntityID(principal) @@ -182,6 +201,16 @@ func (f *EntityFactory) CreateEntitiesForRequest( if serverName != "" { resourceParents = append(resourceParents, cedar.NewEntityUID("MCP", cedar.String(serverName))) } + if backendID != "" { + backendUID := cedar.NewEntityUID(EntityTypeBackend, cedar.String(backendID)) + resourceParents = append(resourceParents, backendUID) + entities[backendUID] = cedar.Entity{ + UID: backendUID, + Parents: cedar.NewEntityUIDSet(), + Attributes: cedar.NewRecord(cedar.RecordMap{}), + Tags: cedar.NewRecord(cedar.RecordMap{}), + } + } // Create resource entity resourceUID, resourceEntity := f.CreateResourceEntity(resourceType, resourceID, attributes, resourceParents...) diff --git a/pkg/authz/authorizers/cedar/entity_test.go b/pkg/authz/authorizers/cedar/entity_test.go index 857f23338a..3333739450 100644 --- a/pkg/authz/authorizers/cedar/entity_test.go +++ b/pkg/authz/authorizers/cedar/entity_test.go @@ -427,7 +427,8 @@ func TestCreateCedarEntities(t *testing.T) { factory := NewEntityFactory("") // Create Cedar entities (no groups for these test cases) - entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "") + entities, err := factory.CreateEntitiesForRequest( + tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "") // Check error expectations if tc.expectErr { @@ -594,3 +595,64 @@ func TestCreateEntitiesForRequest_MCPParent(t *testing.T) { }) } } + +func TestCreateEntitiesForRequest_BackendParent(t *testing.T) { + t.Parallel() + + factory := NewEntityFactory("") + tests := []struct { + name string + serverName string + backendID string + wantParentCount int + wantBackendEntity bool + }{ + { + name: "MCP and Backend parents", + serverName: "main-vmcp", + backendID: "github-mcp", + wantParentCount: 2, + wantBackendEntity: true, + }, + { + name: "empty BackendID keeps only MCP parent", + serverName: "main-vmcp", + wantParentCount: 1, + }, + { + name: "Backend parent does not require MCP parent", + backendID: "github-mcp", + wantParentCount: 1, + wantBackendEntity: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + entities, err := factory.createEntitiesForRequest( + "Client::user1", + "Action::call_tool", + "Tool::renamed-search", + map[string]interface{}{"sub": "user1"}, + map[string]interface{}{"name": "renamed-search"}, + nil, + tt.serverName, + tt.backendID, + ) + require.NoError(t, err) + + toolUID := cedar.NewEntityUID("Tool", cedar.String("renamed-search")) + toolEntity, ok := entities[toolUID] + require.True(t, ok) + assert.Equal(t, tt.wantParentCount, toolEntity.Parents.Len()) + + backendUID := cedar.NewEntityUID(EntityTypeBackend, cedar.String(tt.backendID)) + assert.Equal(t, tt.wantBackendEntity, toolEntity.Parents.Contains(backendUID)) + _, backendExists := entities[backendUID] + assert.Equal(t, tt.wantBackendEntity, backendExists, + "Backend entity must be materialized for Cedar hierarchy traversal") + }) + } +} diff --git a/pkg/authz/authorizers/resource_metadata.go b/pkg/authz/authorizers/resource_metadata.go new file mode 100644 index 0000000000..326445a4a9 --- /dev/null +++ b/pkg/authz/authorizers/resource_metadata.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authorizers + +import "context" + +// ResourceMetadata carries trusted server-side facts about the resource being +// authorized that are not part of the public Authorizer method signature. +// +// BackendID is the logical vMCP backend identifier. It MUST be sourced from the +// aggregated capability, never from client-supplied request data such as tool +// arguments or an advertised-name prefix. +type ResourceMetadata struct { + BackendID string +} + +// resourceMetadataKey is the unexported context key used by +// WithResourceMetadata and ResourceMetadataFromContext. +type resourceMetadataKey struct{} + +// WithResourceMetadata stores trusted resource metadata in ctx. +func WithResourceMetadata(ctx context.Context, metadata ResourceMetadata) context.Context { + return context.WithValue(ctx, resourceMetadataKey{}, metadata) +} + +// ResourceMetadataFromContext retrieves trusted resource metadata previously +// stored with WithResourceMetadata. The second return value is false when no +// metadata is present. +func ResourceMetadataFromContext(ctx context.Context) (ResourceMetadata, bool) { + metadata, ok := ctx.Value(resourceMetadataKey{}).(ResourceMetadata) + return metadata, ok +} diff --git a/pkg/authz/authorizers/resource_metadata_test.go b/pkg/authz/authorizers/resource_metadata_test.go new file mode 100644 index 0000000000..8670a46f96 --- /dev/null +++ b/pkg/authz/authorizers/resource_metadata_test.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authorizers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResourceMetadataContext(t *testing.T) { + t.Parallel() + + t.Run("round trip", func(t *testing.T) { + t.Parallel() + + want := ResourceMetadata{BackendID: "github-mcp"} + ctx := WithResourceMetadata(t.Context(), want) + + got, ok := ResourceMetadataFromContext(ctx) + assert.True(t, ok) + assert.Equal(t, want, got) + }) + + t.Run("missing", func(t *testing.T) { + t.Parallel() + + got, ok := ResourceMetadataFromContext(t.Context()) + assert.False(t, ok) + assert.Empty(t, got) + }) +} diff --git a/pkg/vmcp/core/admission.go b/pkg/vmcp/core/admission.go index 9f84e2d4e4..fef0bc0158 100644 --- a/pkg/vmcp/core/admission.go +++ b/pkg/vmcp/core/admission.go @@ -153,7 +153,7 @@ func (a *cedarAdmission) FilterTools( filtered := make([]vmcp.Tool, 0, len(tools)) for i := range tools { tool := &tools[i] - toolCtx := ctx + toolCtx := authorizers.WithResourceMetadata(ctx, authorizers.ResourceMetadata{BackendID: tool.BackendID}) if ann := convertAnnotations(tool.Annotations); ann != nil { toolCtx = authorizers.WithToolAnnotations(toolCtx, ann) } @@ -180,6 +180,7 @@ func (a *cedarAdmission) AllowToolCall( ctx context.Context, identity *auth.Identity, tool *vmcp.Tool, args map[string]any, ) (bool, error) { ctx = auth.WithIdentity(ctx, identity) + ctx = authorizers.WithResourceMetadata(ctx, authorizers.ResourceMetadata{BackendID: tool.BackendID}) if ann := convertAnnotations(tool.Annotations); ann != nil { ctx = authorizers.WithToolAnnotations(ctx, ann) } diff --git a/pkg/vmcp/core/admission_test.go b/pkg/vmcp/core/admission_test.go index cba2edafb8..cd0b23f152 100644 --- a/pkg/vmcp/core/admission_test.go +++ b/pkg/vmcp/core/admission_test.go @@ -37,13 +37,15 @@ type mockResult struct { } type mockCall struct { - feature authorizers.MCPFeature - operation authorizers.MCPOperation - resourceID string - args map[string]interface{} - identitySubject string // "" when no identity was present in ctx - identityPresent bool - annotations *authorizers.ToolAnnotations + feature authorizers.MCPFeature + operation authorizers.MCPOperation + resourceID string + args map[string]interface{} + identitySubject string // "" when no identity was present in ctx + identityPresent bool + annotations *authorizers.ToolAnnotations + resourceMetadata authorizers.ResourceMetadata + metadataPresent bool } func (m *mockAuthorizer) AuthorizeWithJWTClaims( @@ -54,13 +56,16 @@ func (m *mockAuthorizer) AuthorizeWithJWTClaims( args map[string]interface{}, ) (bool, error) { id, present := auth.IdentityFromContext(ctx) + metadata, metadataPresent := authorizers.ResourceMetadataFromContext(ctx) call := mockCall{ - feature: feature, - operation: operation, - resourceID: resourceID, - args: args, - identityPresent: present, - annotations: authorizers.ToolAnnotationsFromContext(ctx), + feature: feature, + operation: operation, + resourceID: resourceID, + args: args, + identityPresent: present, + annotations: authorizers.ToolAnnotationsFromContext(ctx), + resourceMetadata: metadata, + metadataPresent: metadataPresent, } if present && id != nil { call.identitySubject = id.Subject @@ -184,8 +189,8 @@ func TestCedarAdmission_InvokesToolAuthorizerCorrectly(t *testing.T) { adm := newCedarAdmission(mock) tools := []vmcp.Tool{ - {Name: "hinted", Annotations: &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(true)}}, - {Name: "plain"}, + {Name: "hinted", BackendID: "backend-a", Annotations: &vmcp.ToolAnnotations{ReadOnlyHint: boolPtr(true)}}, + {Name: "plain", BackendID: "backend-b"}, } _, err := adm.FilterTools(context.Background(), cedarIdentity(), tools) require.NoError(t, err) @@ -196,12 +201,15 @@ func TestCedarAdmission_InvokesToolAuthorizerCorrectly(t *testing.T) { assert.Equal(t, authorizers.MCPOperationCall, c.operation) assert.True(t, c.identityPresent, "adapter must re-inject identity into ctx") assert.Equal(t, "user123", c.identitySubject) + assert.True(t, c.metadataPresent, "adapter must inject trusted resource metadata") } // Annotations are injected only for the tool that carries a hint. byName := map[string]mockCall{mock.calls[0].resourceID: mock.calls[0], mock.calls[1].resourceID: mock.calls[1]} require.NotNil(t, byName["hinted"].annotations) assert.Equal(t, boolPtr(true), byName["hinted"].annotations.ReadOnlyHint) + assert.Equal(t, "backend-a", byName["hinted"].resourceMetadata.BackendID) assert.Nil(t, byName["plain"].annotations, "no annotation ctx written when the tool has no hints") + assert.Equal(t, "backend-b", byName["plain"].resourceMetadata.BackendID) } func TestCedarAdmission_AllowToolCall(t *testing.T) { @@ -241,6 +249,42 @@ func TestCedarAdmission_AllowToolCall(t *testing.T) { } } +func TestCedarAdmission_BackendScopedPolicy(t *testing.T) { + t.Parallel() + + adm := cedarAdmissionWith(t, + `permit(principal, action == Action::"call_tool", resource in Backend::"backend-a");`) + id := cedarIdentity() + ctx := context.Background() + + allowed := vmcp.Tool{Name: "manually-renamed", BackendID: "backend-a"} + nameSpoof := vmcp.Tool{Name: "backend-a_looks-allowed", BackendID: "backend-b"} + composite := vmcp.Tool{Name: "workflow", BackendID: ""} + + got, err := adm.FilterTools(ctx, id, []vmcp.Tool{allowed, nameSpoof, composite}) + require.NoError(t, err) + assert.Equal(t, []string{"manually-renamed"}, toolNames(got), + "authorization must use BackendID rather than the advertised name") + + for _, tc := range []struct { + name string + tool *vmcp.Tool + want bool + }{ + {name: "matching backend", tool: &allowed, want: true}, + {name: "misleading name from another backend", tool: &nameSpoof, want: false}, + {name: "empty BackendID", tool: &composite, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ok, err := adm.AllowToolCall(ctx, id, tc.tool, nil) + require.NoError(t, err) + assert.Equal(t, tc.want, ok) + }) + } +} + // TestCedarAdmission_AllowToolCall_ForwardsArgs asserts the call's args reach the // authorizer (the arg-gated-policy input path), both via the mock and against a // real Cedar arg-gated policy (mirroring pkg/authz TestAuthorizeToolCall_WithArguments).