Skip to content

Commit c1504ce

Browse files
committed
Add backend-scoped vMCP authorization
1 parent aa913cb commit c1504ce

11 files changed

Lines changed: 438 additions & 39 deletions

File tree

‎docs/arch/10-virtual-mcp-architecture.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,22 @@ the list side (`ListTools`/`ListResources`/`ListPrompts` filter the advertised s
10521052
the call side (`CallTool`/`ReadResource`/`GetPrompt` deny before dispatch), closing the
10531053
"list says yes / call says no" gap.
10541054

1055+
For tool decisions, admission carries the advertised capability's trusted logical
1056+
`BackendID` into Cedar. The request's `Tool` entity remains a child of the vMCP's
1057+
`MCP` entity and also becomes a child of `Backend::<BackendID>`. Direct
1058+
`resource in Backend::"..."` policies evaluate from that parent UID without a
1059+
Backend entity in the request map; `entities_json` is needed only when the Backend
1060+
has attributes or participates in a transitive hierarchy. Tool names and arguments
1061+
are never used to infer backend membership.
1062+
1063+
Composite tools have no single origin and therefore no Backend parent. Neither a
1064+
backend-scoped `permit` nor a backend-scoped `forbid` matches them. A composite
1065+
authorized by another policy may still invoke steps on a backend named by such a
1066+
`forbid`, because workflow steps route internally rather than entering admission as
1067+
separate top-level tool calls. Policies that restrict backends must encode the
1068+
allowed backends in `permit` conditions and authorize composite Tool entities
1069+
separately.
1070+
10551071
Because the SDK maps a call-side deny to a tool result, a raw denied `tools/call` would
10561072
otherwise return **HTTP 200** (either the SDK's `-32602 "not found"` for a list-filtered
10571073
tool, or a `200 + IsError` tool result for an argument-gated deny). To make a denial a

‎docs/authz.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,43 @@ permit(principal, action == Action::"call_tool", resource == Tool::"weather");
239239

240240
This policy allows any client to call the weather tool.
241241

242+
##### Allow tools from a specific vMCP backend
243+
244+
For tools advertised by a Virtual MCP Server, Cedar receives the logical
245+
originating backend as a second resource parent:
246+
247+
```text
248+
Tool::"search"
249+
-> MCP::"main-vmcp"
250+
-> Backend::"github-mcp"
251+
```
252+
253+
This allows every tool from one backend without relying on the advertised tool
254+
name or its conflict-resolution prefix:
255+
256+
```plain
257+
permit(
258+
principal,
259+
action == Action::"call_tool",
260+
resource in Backend::"github-mcp"
261+
);
262+
```
263+
264+
The Backend entity ID is the tool's logical vMCP `BackendID`, not a network
265+
address. ToolHive obtains it from the aggregated capability and uses the same
266+
value for list filtering and call authorization. A direct Backend policy does
267+
not require an entry in `entities_json`: Cedar evaluates it from the Backend UID
268+
in the Tool entity's parent set. Define the Backend in `entities_json` only when
269+
it needs attributes or parents for a transitive hierarchy.
270+
271+
Composite tools have no single originating backend, so they have no Backend
272+
parent. Consequently, neither a backend-scoped `permit` nor a backend-scoped
273+
`forbid` matches a composite tool. If another policy permits the composite, a
274+
backend-scoped `forbid` does not prevent its workflow steps from reaching that
275+
backend because workflow steps are not authorized as separate top-level tool
276+
calls. Express backend restrictions as conditions on `permit` policies and
277+
authorize composite tools separately by their `Tool` entity.
278+
242279
##### Allow a specific prompt
243280

244281
```plain

‎pkg/authz/authorizers/annotations.go‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,21 @@ func WithToolAnnotations(ctx context.Context, annotations *ToolAnnotations) cont
4242
}
4343

4444
// ToolAnnotationsFromContext retrieves tool annotations previously stored with
45-
// WithToolAnnotations. It returns nil when no annotations are present.
45+
// WithToolAnnotations or as part of ResourceMetadata. It returns nil when no
46+
// annotations are present.
4647
func ToolAnnotationsFromContext(ctx context.Context) *ToolAnnotations {
47-
v, _ := ctx.Value(toolAnnotationsKey{}).(*ToolAnnotations)
48-
return v
48+
return effectiveToolAnnotationsFromContext(ctx)
49+
}
50+
51+
// effectiveToolAnnotationsFromContext keeps the legacy and unified metadata
52+
// readers consistent when both context channels are present. Unified resource
53+
// metadata is authoritative; the legacy value is a compatibility fallback.
54+
func effectiveToolAnnotationsFromContext(ctx context.Context) *ToolAnnotations {
55+
if metadata, ok := ctx.Value(resourceMetadataKey{}).(ResourceMetadata); ok && metadata.Annotations != nil {
56+
return metadata.Annotations
57+
}
58+
annotations, _ := ctx.Value(toolAnnotationsKey{}).(*ToolAnnotations)
59+
return annotations
4960
}
5061

5162
// AnnotationsToMap converts non-nil annotation fields to a flat map suitable

‎pkg/authz/authorizers/cedar/core.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,18 +1079,27 @@ func (a *Authorizer) authorizeToolCall(
10791079
// Annotations are merged first so that the standard attributes ("name",
10801080
// "operation", "feature") always take precedence and cannot be overwritten
10811081
// by annotation keys — intentionally or accidentally.
1082-
annotationAttrs := authorizers.AnnotationsToMap(authorizers.ToolAnnotationsFromContext(ctx))
1082+
resourceMetadata, _ := authorizers.ResourceMetadataFromContext(ctx)
1083+
annotationAttrs := authorizers.AnnotationsToMap(resourceMetadata.Annotations)
10831084

10841085
// Create attributes for the entities
10851086
attributes := mergeContexts(annotationAttrs, attrsMap, map[string]interface{}{
10861087
"name": toolName,
10871088
"operation": "call",
10881089
"feature": "tool",
10891090
})
1091+
resourceParents := func() []cedar.EntityUID {
1092+
if resourceMetadata.BackendID == "" {
1093+
return nil
1094+
}
1095+
return []cedar.EntityUID{
1096+
cedar.NewEntityUID(EntityTypeBackend, cedar.String(resourceMetadata.BackendID)),
1097+
}
1098+
}()
10901099

10911100
// Create Cedar entities
10921101
entities, err := a.entityFactory.CreateEntitiesForRequest(
1093-
principal, action, resource, claimsMap, attributes, groups, a.serverName,
1102+
principal, action, resource, claimsMap, attributes, groups, a.serverName, resourceParents...,
10941103
)
10951104
if err != nil {
10961105
return false, fmt.Errorf("failed to create Cedar entities: %w", err)

‎pkg/authz/authorizers/cedar/core_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2305,6 +2305,52 @@ func TestAuthorizeWithJWTClaims_TransitiveHierarchyPreserved(t *testing.T) {
23052305
"transitive hierarchy THVGroup→THVRole from entities_json must survive entity merge")
23062306
}
23072307

2308+
func TestAuthorizeWithJWTClaims_BackendHierarchyPreserved(t *testing.T) {
2309+
t.Parallel()
2310+
2311+
policy := `permit(
2312+
principal,
2313+
action == Action::"call_tool",
2314+
resource in BackendGroup::"production-approved"
2315+
);`
2316+
entitiesJSON := `[
2317+
{
2318+
"uid": {"type": "Backend", "id": "github-mcp"},
2319+
"attrs": {"environment": "production"},
2320+
"parents": [{"type": "BackendGroup", "id": "production-approved"}]
2321+
},
2322+
{
2323+
"uid": {"type": "BackendGroup", "id": "production-approved"},
2324+
"attrs": {},
2325+
"parents": []
2326+
}
2327+
]`
2328+
2329+
authorizer, err := NewCedarAuthorizer(ConfigOptions{
2330+
Policies: []string{policy},
2331+
EntitiesJSON: entitiesJSON,
2332+
}, "main-vmcp")
2333+
require.NoError(t, err)
2334+
2335+
identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{
2336+
Subject: "user1",
2337+
Claims: map[string]any{"sub": "user1"},
2338+
}}
2339+
ctx := auth.WithIdentity(context.Background(), identity)
2340+
ctx = authorizers.WithResourceMetadata(ctx, authorizers.ResourceMetadata{BackendID: "github-mcp"})
2341+
2342+
authorized, err := authorizer.AuthorizeWithJWTClaims(
2343+
ctx,
2344+
authorizers.MCPFeatureTool,
2345+
authorizers.MCPOperationCall,
2346+
"renamed-search",
2347+
nil,
2348+
)
2349+
require.NoError(t, err)
2350+
assert.True(t, authorized,
2351+
"the Tool parent UID must traverse the configured Backend hierarchy")
2352+
}
2353+
23082354
// TestAuthorizeWithJWTClaims_DoesNotMutateIdentity verifies that
23092355
// AuthorizeWithJWTClaims does not mutate the Identity stored in context.
23102356
// The Identity contract (see auth.Identity) requires that the struct MUST NOT

‎pkg/authz/authorizers/cedar/entity.go‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ import (
1717
// maxSchemaDepth in pkg/vmcp/composer/elicitation_handler.go for consistency.
1818
const maxClaimNestingDepth = 10
1919

20-
// EntityTypeTHVGroup is the default Cedar entity type representing group membership.
21-
// It is used when ConfigOptions.GroupEntityType is empty. Principals are added as
22-
// children of group entities so that Cedar's `in` operator can evaluate
23-
// group-based policies (e.g. `principal in THVGroup::"engineering"`).
24-
const EntityTypeTHVGroup cedar.EntityType = "THVGroup"
20+
const (
21+
// EntityTypeTHVGroup is the default Cedar entity type representing group membership.
22+
// It is used when ConfigOptions.GroupEntityType is empty. Principals are added as
23+
// children of group entities so that Cedar's `in` operator can evaluate
24+
// group-based policies (e.g. `principal in THVGroup::"engineering"`).
25+
EntityTypeTHVGroup cedar.EntityType = "THVGroup"
26+
// EntityTypeBackend represents a logical vMCP backend. Tools are added as
27+
// children of their originating backend for backend-scoped policies.
28+
EntityTypeBackend cedar.EntityType = "Backend"
29+
)
2530

2631
// EntityFactory creates Cedar entities for authorization.
2732
type EntityFactory struct {
@@ -130,13 +135,17 @@ func (*EntityFactory) CreateResourceEntity(
130135
// When serverName is non-empty, resource entities include an MCP parent UID so
131136
// that server-scoped Cedar policies (e.g. `resource in MCP::"github"`) evaluate
132137
// correctly via Cedar's `in` operator. When serverName is empty, resource
133-
// entities have no parents, preserving backward compatibility.
138+
// entities have no MCP parent, preserving backward compatibility. Additional
139+
// resource parent UIDs may be supplied for narrower scopes such as vMCP
140+
// backends; their corresponding entities, if needed for hierarchy traversal,
141+
// must come from entities_json.
134142
func (f *EntityFactory) CreateEntitiesForRequest(
135143
principal, action, resource string,
136144
claimsMap map[string]interface{},
137145
attributes map[string]interface{},
138146
groups []string,
139147
serverName string,
148+
extraResourceParents ...cedar.EntityUID,
140149
) (cedar.EntityMap, error) {
141150
// Parse principal, action, and resource
142151
principalType, principalID, err := parseCedarEntityID(principal)
@@ -178,10 +187,11 @@ func (f *EntityFactory) CreateEntitiesForRequest(
178187

179188
// Build MCP parent for resource entity when serverName is set so that
180189
// server-scoped policies (e.g. resource in MCP::"github") can match.
181-
var resourceParents []cedar.EntityUID
190+
resourceParents := make([]cedar.EntityUID, 0, 1+len(extraResourceParents))
182191
if serverName != "" {
183192
resourceParents = append(resourceParents, cedar.NewEntityUID("MCP", cedar.String(serverName)))
184193
}
194+
resourceParents = append(resourceParents, extraResourceParents...)
185195

186196
// Create resource entity
187197
resourceUID, resourceEntity := f.CreateResourceEntity(resourceType, resourceID, attributes, resourceParents...)

‎pkg/authz/authorizers/cedar/entity_test.go‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,8 @@ func TestCreateCedarEntities(t *testing.T) {
427427
factory := NewEntityFactory("")
428428

429429
// Create Cedar entities (no groups for these test cases)
430-
entities, err := factory.CreateEntitiesForRequest(tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "")
430+
entities, err := factory.CreateEntitiesForRequest(
431+
tc.principal, tc.action, tc.resource, tc.claimsMap, tc.attributes, nil, "")
431432

432433
// Check error expectations
433434
if tc.expectErr {
@@ -594,3 +595,71 @@ func TestCreateEntitiesForRequest_MCPParent(t *testing.T) {
594595
})
595596
}
596597
}
598+
599+
func TestCreateEntitiesForRequest_BackendParent(t *testing.T) {
600+
t.Parallel()
601+
602+
factory := NewEntityFactory("")
603+
tests := []struct {
604+
name string
605+
serverName string
606+
backendID string
607+
wantParentCount int
608+
}{
609+
{
610+
name: "MCP and Backend parents",
611+
serverName: "main-vmcp",
612+
backendID: "github-mcp",
613+
wantParentCount: 2,
614+
},
615+
{
616+
name: "empty BackendID keeps only MCP parent",
617+
serverName: "main-vmcp",
618+
wantParentCount: 1,
619+
},
620+
{
621+
name: "Backend parent does not require MCP parent",
622+
backendID: "github-mcp",
623+
wantParentCount: 1,
624+
},
625+
}
626+
627+
for _, tt := range tests {
628+
t.Run(tt.name, func(t *testing.T) {
629+
t.Parallel()
630+
631+
var extraResourceParents []cedar.EntityUID
632+
if tt.backendID != "" {
633+
extraResourceParents = append(extraResourceParents,
634+
cedar.NewEntityUID(EntityTypeBackend, cedar.String(tt.backendID)))
635+
}
636+
637+
entities, err := factory.CreateEntitiesForRequest(
638+
"Client::user1",
639+
"Action::call_tool",
640+
"Tool::renamed-search",
641+
map[string]interface{}{"sub": "user1"},
642+
map[string]interface{}{"name": "renamed-search"},
643+
nil,
644+
tt.serverName,
645+
extraResourceParents...,
646+
)
647+
require.NoError(t, err)
648+
assert.Len(t, entities, 3,
649+
"request entities must contain only the principal, action, and resource")
650+
651+
toolUID := cedar.NewEntityUID("Tool", cedar.String("renamed-search"))
652+
toolEntity, ok := entities[toolUID]
653+
require.True(t, ok)
654+
assert.Equal(t, tt.wantParentCount, toolEntity.Parents.Len())
655+
656+
if tt.backendID != "" {
657+
backendUID := cedar.NewEntityUID(EntityTypeBackend, cedar.String(tt.backendID))
658+
assert.True(t, toolEntity.Parents.Contains(backendUID),
659+
"Tool must carry the trusted Backend parent UID")
660+
assert.NotContains(t, entities, backendUID,
661+
"request-time Backend parents must not be materialized as entities")
662+
}
663+
})
664+
}
665+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package authorizers
5+
6+
import "context"
7+
8+
// ResourceMetadata carries trusted server-side facts about the resource being
9+
// authorized that are not part of the public Authorizer method signature.
10+
//
11+
// BackendID is the logical vMCP backend identifier. It MUST be sourced from the
12+
// aggregated capability, never from client-supplied request data such as tool
13+
// arguments or an advertised-name prefix. Annotations carry the tool hints from
14+
// that same trusted capability.
15+
type ResourceMetadata struct {
16+
BackendID string
17+
Annotations *ToolAnnotations
18+
}
19+
20+
// resourceMetadataKey is the unexported context key used by
21+
// WithResourceMetadata and ResourceMetadataFromContext.
22+
type resourceMetadataKey struct{}
23+
24+
// WithResourceMetadata stores trusted resource metadata in ctx.
25+
func WithResourceMetadata(ctx context.Context, metadata ResourceMetadata) context.Context {
26+
return context.WithValue(ctx, resourceMetadataKey{}, metadata)
27+
}
28+
29+
// ResourceMetadataFromContext retrieves trusted resource metadata previously
30+
// stored with WithResourceMetadata. For compatibility, annotations stored with
31+
// WithToolAnnotations are returned when unified metadata has no annotations.
32+
// The second return value reports whether either metadata channel is present.
33+
func ResourceMetadataFromContext(ctx context.Context) (ResourceMetadata, bool) {
34+
metadata, ok := ctx.Value(resourceMetadataKey{}).(ResourceMetadata)
35+
if metadata.Annotations == nil {
36+
metadata.Annotations = effectiveToolAnnotationsFromContext(ctx)
37+
ok = ok || metadata.Annotations != nil
38+
}
39+
return metadata, ok
40+
}

0 commit comments

Comments
 (0)