Skip to content

Commit 60f0184

Browse files
authored
Add backend-scoped vMCP authorization (#6487)
1 parent ba686b2 commit 60f0184

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
@@ -1189,6 +1189,22 @@ the list side (`ListTools`/`ListResources`/`ListPrompts` filter the advertised s
11891189
the call side (`CallTool`/`ReadResource`/`GetPrompt` deny before dispatch), closing the
11901190
"list says yes / call says no" gap.
11911191

1192+
For tool decisions, admission carries the advertised capability's trusted logical
1193+
`BackendID` into Cedar. The request's `Tool` entity remains a child of the vMCP's
1194+
`MCP` entity and also becomes a child of `Backend::<BackendID>`. Direct
1195+
`resource in Backend::"..."` policies evaluate from that parent UID without a
1196+
Backend entity in the request map; `entities_json` is needed only when the Backend
1197+
has attributes or participates in a transitive hierarchy. Tool names and arguments
1198+
are never used to infer backend membership.
1199+
1200+
Composite tools have no single origin and therefore no Backend parent. Neither a
1201+
backend-scoped `permit` nor a backend-scoped `forbid` matches them. A composite
1202+
authorized by another policy may still invoke steps on a backend named by such a
1203+
`forbid`, because workflow steps route internally rather than entering admission as
1204+
separate top-level tool calls. Policies that restrict backends must encode the
1205+
allowed backends in `permit` conditions and authorize composite Tool entities
1206+
separately.
1207+
11921208
Because the SDK maps a call-side deny to a tool result, a raw denied `tools/call` would
11931209
otherwise return **HTTP 200** (either the SDK's `-32602 "not found"` for a list-filtered
11941210
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
@@ -260,6 +260,43 @@ permit(principal, action == Action::"call_tool", resource == Tool::"weather");
260260

261261
This policy allows any client to call the weather tool.
262262

263+
##### Allow tools from a specific vMCP backend
264+
265+
For tools advertised by a Virtual MCP Server, Cedar receives the logical
266+
originating backend as a second resource parent:
267+
268+
```text
269+
Tool::"search"
270+
-> MCP::"main-vmcp"
271+
-> Backend::"github-mcp"
272+
```
273+
274+
This allows every tool from one backend without relying on the advertised tool
275+
name or its conflict-resolution prefix:
276+
277+
```plain
278+
permit(
279+
principal,
280+
action == Action::"call_tool",
281+
resource in Backend::"github-mcp"
282+
);
283+
```
284+
285+
The Backend entity ID is the tool's logical vMCP `BackendID`, not a network
286+
address. ToolHive obtains it from the aggregated capability and uses the same
287+
value for list filtering and call authorization. A direct Backend policy does
288+
not require an entry in `entities_json`: Cedar evaluates it from the Backend UID
289+
in the Tool entity's parent set. Define the Backend in `entities_json` only when
290+
it needs attributes or parents for a transitive hierarchy.
291+
292+
Composite tools have no single originating backend, so they have no Backend
293+
parent. Consequently, neither a backend-scoped `permit` nor a backend-scoped
294+
`forbid` matches a composite tool. If another policy permits the composite, a
295+
backend-scoped `forbid` does not prevent its workflow steps from reaching that
296+
backend because workflow steps are not authorized as separate top-level tool
297+
calls. Express backend restrictions as conditions on `permit` policies and
298+
authorize composite tools separately by their `Tool` entity.
299+
263300
##### Allow a specific prompt
264301

265302
```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
@@ -1087,18 +1087,27 @@ func (a *Authorizer) authorizeToolCall(
10871087
// Annotations are merged first so that the standard attributes ("name",
10881088
// "operation", "feature") always take precedence and cannot be overwritten
10891089
// by annotation keys — intentionally or accidentally.
1090-
annotationAttrs := authorizers.AnnotationsToMap(authorizers.ToolAnnotationsFromContext(ctx))
1090+
resourceMetadata, _ := authorizers.ResourceMetadataFromContext(ctx)
1091+
annotationAttrs := authorizers.AnnotationsToMap(resourceMetadata.Annotations)
10911092

10921093
// Create attributes for the entities
10931094
attributes := mergeContexts(annotationAttrs, attrsMap, map[string]interface{}{
10941095
"name": toolName,
10951096
"operation": "call",
10961097
"feature": "tool",
10971098
})
1099+
resourceParents := func() []cedar.EntityUID {
1100+
if resourceMetadata.BackendID == "" {
1101+
return nil
1102+
}
1103+
return []cedar.EntityUID{
1104+
cedar.NewEntityUID(EntityTypeBackend, cedar.String(resourceMetadata.BackendID)),
1105+
}
1106+
}()
10981107

10991108
// Create Cedar entities
11001109
entities, err := a.entityFactory.CreateEntitiesForRequest(
1101-
principal, action, resource, claimsMap, attributes, groups, a.serverName,
1110+
principal, action, resource, claimsMap, attributes, groups, a.serverName, resourceParents...,
11021111
)
11031112
if err != nil {
11041113
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)