Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/arch/10-virtual-mcp-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::<BackendID>`. 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
Expand Down
29 changes: 29 additions & 0 deletions docs/authz.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions pkg/authz/authorizers/cedar/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions pkg/authz/authorizers/cedar/core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 34 additions & 5 deletions pkg/authz/authorizers/cedar/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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...)
Expand Down
64 changes: 63 additions & 1 deletion pkg/authz/authorizers/cedar/entity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
})
}
}
33 changes: 33 additions & 0 deletions pkg/authz/authorizers/resource_metadata.go
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions pkg/authz/authorizers/resource_metadata_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
3 changes: 2 additions & 1 deletion pkg/vmcp/core/admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading
Loading