Skip to content

Add backend-scoped vMCP authorization - #6487

Open
Sanskarzz wants to merge 1 commit into
stacklok:mainfrom
Sanskarzz:ceder-auth-vmcp
Open

Sanskarzz wants to merge 1 commit into
stacklok:mainfrom
Sanskarzz:ceder-auth-vmcp

Conversation

@Sanskarzz

@Sanskarzz Sanskarzz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

VirtualMCPServer currently exposes each tool as a Cedar Tool entity whose only resource parent is the vMCP itself. As a result, administrators cannot grant access to every tool from one discovered backend without enumerating tools or relying on conflict-resolution name prefixes.

  • Carry each aggregated tool's trusted logical BackendID into Cedar authorization.
  • Add the logical Backend UID to the request Tool entity's parents while preserving its existing vMCP MCP parent.
  • Apply the same trusted metadata during tool-list filtering and tool-call authorization.
  • Keep entities_json as the source of Backend attributes and transitive parent hierarchies.
  • Document and test backend-scoped policy behavior for regular and composite tools.

Fixes #5483

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Verified the rebased change with the repository's Taskfile commands:

task test
task lint-fix
task build
task license-check

The full race-enabled unit suite passes. Coverage includes direct backend membership without a request-time Backend entity, different-backend denial despite a misleading tool name, list/call parity, configured transitive Backend hierarchies, unified metadata compatibility, and composite behavior under backend-scoped permit and forbid policies. task lint-fix reports zero issues; the build and license-header checks also pass.

Changes

File Change
pkg/vmcp/core/admission.go Forwards the tool's trusted BackendID and annotations through one metadata path for list and call decisions.
pkg/authz/authorizers/ Adds typed trusted resource metadata while preserving compatibility with existing annotation callers.
pkg/authz/authorizers/cedar/ Adds the Backend parent UID to Tool entities and uses configured Backend entities only for attributes or transitive hierarchies.
pkg/vmcp/core/admission_test.go Exercises list/call parity, name-prefix spoof resistance, empty BackendIDs, and composite forbid behavior.
docs/authz.md, docs/arch/10-virtual-mcp-architecture.md Documents the policy model, trust boundary, direct versus transitive membership, and composite-tool behavior.

Does this introduce a user-facing change?

Yes. Cedar policies for a VirtualMCPServer can authorize all tools originating from one backend with resource in Backend::"<backend-id>", without listing individual tools or depending on advertised tool names.

Implementation plan

Approved implementation plan
  1. Use vmcp.Tool.BackendID, which is attached by the aggregation layer, as the trusted backend identity.
  2. Carry that identity and the tool annotations through one internal resource-metadata channel without changing the generic Authorizer interface or the external authorization contract.
  3. For Cedar tool decisions, add Backend::<BackendID> as a resource parent UID. Direct resource in Backend::"..." evaluation uses the Tool's parent set and does not require a request-time Backend entity.
  4. Keep the existing MCP::<vMCP-name> parent to preserve current policies.
  5. Use entities_json Backend entities only when policies need Backend attributes or transitive parent hierarchies.
  6. Use the same metadata path for list filtering and call enforcement.
  7. Keep composite tools outside Backend scope because they have no single origin, and document and test the resulting permit and forbid behavior.

Special notes for reviewers

  • The BackendID comes from the server-side aggregated capability. It is never inferred from client arguments or the advertised tool name.
  • A direct Backend policy needs only the Backend UID in the Tool entity's parent set. Backend entities in entities_json remain available for attributes and transitive hierarchy traversal.
  • Existing exact-tool and vMCP-scoped policies are unchanged because the original MCP parent remains.
  • Composite tools have an empty BackendID because they may span multiple backends, so this PR intentionally gives them no Backend parent. Neither a backend-scoped permit nor a backend-scoped forbid matches a composite.
  • If another policy permits a composite, a backend-scoped forbid does not prevent its internal workflow steps from reaching that backend because those steps do not enter admission as separate top-level tool calls. Backend restrictions should therefore be expressed through permit conditions, with composites authorized separately by their Tool entity.
  • This is intentionally tool-scoped. Backend-scoped resource and prompt authorization can be added separately once their admission semantics are designed.
  • No CRD, generated manifest, or operator ConfigMap shape changes are required.

@Sanskarzz
Sanskarzz force-pushed the ceder-auth-vmcp branch 2 times, most recently from 98169e2 to 13fa0c6 Compare September 2, 2026 12:55
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.93%. Comparing base (aa913cb) to head (c1504ce).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6487      +/-   ##
==========================================
- Coverage   78.93%   78.93%   -0.01%     
==========================================
  Files         782      785       +3     
  Lines       78065    78106      +41     
==========================================
+ Hits        61620    61652      +32     
- Misses      16440    16449       +9     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Sanskarzz
Sanskarzz marked this pull request as ready for review September 2, 2026 13:16

@amirejaz amirejaz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on the trust boundary here — sourcing the backend identity from vmcp.Tool.BackendID on the aggregated capability rather than the advertised name is exactly right, and TestCedarAdmission_BackendScopedPolicy's name-spoof case is the test I most wanted to see. The list/call parity through a single metadata path is also the correct shape.

I'm requesting changes on one point: the Backend entity materialization and the IsAuthorized merge carve-out are both dead code, and the reasoning that justifies them doesn't match how cedar-go evaluates in.

From entityInOne (cedar-go@v1.8.0/internal/eval/evalers.go:935):

if fe, ok := env.Entities.Get(candidate); ok {
    if fe.Parents.Contains(parent) { return true }        // direct hit — parent entity never fetched
    for k := range fe.Parents.All() {
        p, ok := env.Entities.Get(k)
        if !ok || p.Parents.Len() == 0 || ... { continue } // zero-parent placeholder is SKIPPED
        todo = append(todo, k)
    }
}
  1. Direct resource in Backend::"x" resolves on fe.Parents.Contains(parent) against the Tool entity alone. The Backend entity is never looked up.
  2. Transitive resource in BackendGroup::"prod" needs a Backend entity with parents. The materialized placeholder is built with Parents: cedar.NewEntityUIDSet()Parents.Len() == 0 — so the evaluator explicitly skips it. Only the entities_json entity ever works, which is what TestAuthorizeWithJWTClaims_BackendHierarchyPreserved is actually exercising.

I verified this rather than reasoning about it. Checking out this branch, deleting the entity insert at entity.go:207-212 and the carve-out at core.go:530-538 (keeping only resourceParents = append(resourceParents, backendUID)), then running ./pkg/authz/authorizers/... ./pkg/vmcp/core/...:

--- FAIL: TestCreateEntitiesForRequest_BackendParent/MCP_and_Backend_parents
        Messages: Backend entity must be materialized for Cedar hierarchy traversal
--- FAIL: TestCreateEntitiesForRequest_BackendParent/Backend_parent_does_not_require_MCP_parent
ok  github.com/stacklok/toolhive/pkg/vmcp/core

Every behavioural test passes — including the transitive-hierarchy test and all three TestCedarAdmission_BackendScopedPolicy subtests. The only failures are the two assertions on the materialization itself.

This matters beyond line count: IsAuthorized is the generic Cedar primitive, and the carve-out gives Backend inverted collision precedence versus every other entity type. Dropping both restores the invariant EntityFactory already documents at entity.go:181-185 — request entities attach parent UIDs; parent entities come from entities_json. Net effect: ~30 lines become ~3, and the change stops contradicting the comment 20 lines above it.


Two smaller things, neither blocking:

Deferring resources/prompts is the right call — and for a sharper reason than the description gives. vmcp.Resource and vmcp.Prompt do carry BackendID (types.go:428,466), so the filter side would have been trivial — and wrong, because authorizeResourceRead/authorizePromptGet build stubs from URI/name alone (core_checks.go:95,109) with no BackendID. Filter-side-only plumbing would reopen the exact list-allows/call-denies gap this seam exists to close. Worth recording in the follow-up issue that core_checks.go's stub construction has to be fixed first.

Composite tools are deniable-by-omission — footgun or intended? An admin whose only policy is permit(..., resource in Backend::"github-mcp") silently loses every composite tool, since composites get an empty BackendID at core_vmcp.go:651. Fail-closed is right and it's documented, but it'll surface as "my workflows vanished after I tightened policy." Any appetite for a reserved parent (e.g. Backend::"__composite") so composites are addressable rather than only excludable?


Process notes: the branch is 55 commits behind main and needs a rebase. And since the PR adds unit tests, please run task test and check that box rather than reporting a direct go test invocation — the changed packages run in under a second each.

For what it's worth, I confirmed separately that this feature is live in production: server.New routes unconditionally through core.New (pkg/vmcp/server/server.go:477), the legacy AuthzMiddleware field is built but never inserted into any handler chain (pkg/vmcp/server/derive.go:61-65), and CheckToolCall resolves the real aggregated tool via findAdvertisedTool (core_checks.go:37) so BackendID reaches the pre-dispatch 403 gate too. The stale "#5442 hasn't landed" comments in admission.go:33-37 are pre-existing and not this PR's problem.

Comment thread pkg/authz/authorizers/cedar/entity.go Outdated
Comment on lines +204 to +213
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{}),
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: This entity insert is dead code. cedar-go resolves the direct resource in Backend::"x" case on the Tool entity's own Parents set without ever fetching Backend::"x", and it skips this placeholder during transitive traversal because Parents.Len() == 0 (evalers.go:944-949).

It also contradicts the comment at entity.go:181-185, and the group-parent comment it derives from, which establish that this factory attaches parent UIDs and leaves parent entities to entities_json.

Suggested change
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{}),
}
}
if backendID != "" {
resourceParents = append(resourceParents, cedar.NewEntityUID(EntityTypeBackend, cedar.String(backendID)))
}

This keeps both the direct case and the entities_json-backed transitive case working, and makes the carve-out at core.go:530-538 unnecessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing this through cedar-go. Fixed in c1504ce98: request-time Backend entity materialization is removed, and the authorization path now adds only Backend::<BackendID> to the Tool parent UID set. TestCreateEntitiesForRequest_BackendParent verifies both the parent relationship and the absence of a Backend entity in the request map.

Comment thread pkg/authz/authorizers/cedar/core.go Outdated
Comment on lines +530 to +538
// 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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: This exception exists only to undo the collision the materialization at entity.go:207 introduces — remove that and this goes away with it.

As written it makes Backend the one entity type where configured beats request, inverting the merge contract documented three lines above, inside a generic Cedar primitive that shouldn't know vMCP concepts. It's also quietly fragile: correctness depends on this loop running strictly after the a.entities loop, with nothing stating that dependency.

Suggested change
// 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
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. With the placeholder removed, I also removed the Backend-specific merge carve-out from IsAuthorized, restoring the normal request-entity merge behavior. The configured transitive Backend -> BackendGroup case remains covered by TestAuthorizeWithJWTClaims_BackendHierarchyPreserved.

Comment on lines +653 to +655
_, backendExists := entities[backendUID]
assert.Equal(t, tt.wantBackendEntity, backendExists,
"Backend entity must be materialized for Cedar hierarchy traversal")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Tied to the blocker above rather than independently wrong — but these assert an implementation detail rather than behaviour, and they're the only two failures when the dead code is removed.

Suggested change
_, backendExists := entities[backendUID]
assert.Equal(t, tt.wantBackendEntity, backendExists,
"Backend entity must be materialized for Cedar hierarchy traversal")

wantBackendEntity stays in use at :652, so the struct field can remain. Keep that assertion and wantParentCount at :649: those test what Cedar actually consumes, and they'd catch a genuine regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. The test no longer requires a materialized Backend entity. It retains the behavioral assertions for MCP/Backend parent counts and Backend parent membership, and now explicitly verifies that the request map contains only the principal, action, and resource entities.

Comment thread pkg/authz/authorizers/cedar/entity.go Outdated
// 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: An exported and an unexported method differing by one capital letter and one parameter is easy to misread at a call site. Once the materialization is dropped the delta is a single append, so a variadic on the existing exported method collapses this back to one function — all four internal callers (core.go:1092,1131,1177,1217) pass nothing extra:

func (f *EntityFactory) CreateEntitiesForRequest(
	principal, action, resource string,
	claimsMap, attributes map[string]interface{},
	groups []string,
	serverName string,
	extraResourceParents ...cedar.EntityUID,
) (cedar.EntityMap, error)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied this shape. CreateEntitiesForRequest is now the single method and accepts variadic extraResourceParents ...cedar.EntityUID. The Cedar tool authorization path constructs the Backend UID and passes it through that generic mechanism.

// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This is now the second context channel carrying the same category of thing — trusted server-side facts about the resource under authorization — written back-to-back with WithToolAnnotations at admission.go:156-158 and read back-to-back at core.go:1025-1030.

Per the "avoid parallel types that drift" guidance in .claude/rules/go-style.md, a third such fact will have two plausible homes. Consider Annotations *ToolAnnotations as a field here, or adding BackendID where annotations already live.

Not blocking — the ctx approach itself is the right trade, since it avoids modifying the stable Authorizer interface for one implementation's concern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated the new vMCP path: ResourceMetadata now carries both BackendID and Annotations, so list and call admission write one metadata value. Existing annotation helpers remain compatible for current Cedar/HTTP PDP callers through a shared precedence helper. Tests cover legacy reads, unified reads, and conflicting values.

Comment thread pkg/vmcp/core/admission.go Outdated
for i := range tools {
tool := &tools[i]
toolCtx := ctx
toolCtx := authorizers.WithResourceMetadata(ctx, authorizers.ResourceMetadata{BackendID: tool.BackendID})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Metadata is injected unconditionally while annotations on the next line are gated on != nil. Harmless, but two adjacent lines following different rules invites a "which one is the bug?" moment later. Either gate on tool.BackendID != "", or add a few words saying the empty case is deliberate — it is, it fails closed for composites.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added explicit comments on both list and call paths explaining that an empty BackendID is deliberate for backendless composite tools. Backend identity and annotations are also now injected together through ResourceMetadata. TestCedarAdmission_BackendScopedPolicy covers the empty-Backend permit outcome and list/call parity.

@arian-gogani

Copy link
Copy Markdown

One question about the scoping direction, since the tests here cover permit and I could not find the forbid case.

entity.go attaches the Backend parent conditionally:

if backendID != "" {
    backendUID := cedar.NewEntityUID(EntityTypeBackend, cedar.String(backendID))
    resourceParents = append(resourceParents, backendUID)
}

admission.go fills that from tool.BackendID, and composite tools do not carry one: ConvertWorkflowDefsToTools in pkg/vmcp/internal/compositetools/workflow_converter.go builds vmcp.Tool{Name, Description, InputSchema} and never sets BackendID, so it arrives as the zero value.

For a permit that is the safe direction. permit (..., resource in Backend::"payments") does not match a resource with no Backend ancestor, so a composite tool is not permitted by a backend-scoped grant, which is what {empty BackendID, want: false} asserts.

My question is the other direction. forbid (..., resource in Backend::"payments") also does not match a resource with no Backend ancestor. So a composite tool whose steps call into payments is not reached by a forbid naming payments, and if any broader permit covers it the call proceeds. The constraint does not fail to be satisfied, it fails to attach, which makes it absent rather than restrictive.

I traced this through the diff and the converter rather than executing it, because I do not have a Go toolchain here, so treat it as a reading until someone runs it. The check I would run is whether the resource entity has any Backend parent when backendID is empty:

entities, _ := f.createEntitiesForRequest(
    "User::\"alice\"", "Action::\"call_tool\"", "Tool::\"deploy_and_pay\"",
    map[string]interface{}{}, map[string]interface{}{}, []string{}, "srv", "",
)

If the Tool entity comes back with no Backend parent, the forbid has nothing to bind to. If it does get one, I am wrong and this can be ignored.

forbid does not appear anywhere in the diff, including the docs and the tests, which is the reason I looked. That may well be deliberate if composite tools are meant to be governed only by their own name and by step-level checks at execution, in which case saying so in docs/authz.md would close the question, because the natural reading of a backend-scoped forbid is that it covers anything that reaches the backend.

I have spent a while recently on a policy evaluator that returned allow for a policy permitting one principal, because a constraint it did not parse became no constraint rather than an unmet one. Same shape, different cause: there the constraint was dropped at parse time, here it cannot attach for want of a parent entity. That is the only reason I noticed this one.

@arian-gogani

Copy link
Copy Markdown

Following my own comment, because I found the precedent for it in your docs and it makes the question sharper than I put it.

docs/authz.md already documents this class for a different field:

forbid rules over upstream attributes do not survive delegation. Their has guard goes false when the attribute is absent, so the rule never fires and the restriction lifts.

That is the same mechanism as the one I asked about. There an absent attribute makes the has guard false so the forbid never fires. Here an absent Backend parent makes resource in Backend::"x" false so the forbid never attaches. Both end with the restriction lifting rather than the request being denied, and in both cases the policy author sees a rule that reads as restrictive.

The mitigation you already recommend for the first case is the interesting part:

Express upstream restrictions as conditions on a permit rather than as a forbid

If that guidance is the right one, and I think it is, then backend scoping needs the same sentence next to it, because a reader who has internalised "scope with resource in Backend::"x"" from this PR will reach for a forbid the first time they want to exclude something, and they will get a rule that does nothing on exactly the tools that span backends.

So the question narrows to: is a composite tool intended to be outside backend scope entirely, which is defensible since it belongs to no single backend, or is it intended to be covered by a forbid naming any backend its steps reach? Either answer is fine and they need different docs. The second also needs the parent set, presumably to every backend the workflow touches.

Worth adding that the existing caveat is about a claim that may be absent at runtime, which is inherently hard to see coming. This one is structural: ConvertWorkflowDefsToTools never sets BackendID, so it is absent for composite tools always, on every request. That makes it easier to test for, and it is why I would put a forbid case in entity_test.go beside the permit case rather than only in the docs.

Still a reading rather than a run; I have no Go toolchain to hand. The check in my previous comment falsifies it in about a minute if the parent turns out to be there.

@Sanskarzz

Copy link
Copy Markdown
Contributor Author

@arian-gogani Thanks for digging into this. Following up on your original comment and the clarification: your reading was correct.

I chose the first interpretation for this PR: composites remain outside Backend scope because they have no single originating backend. Therefore neither a backend-scoped permit nor a backend-scoped forbid matches a composite. If another policy permits the composite, the Backend forbid does not follow its internal workflow steps.

TestCedarAdmission_BackendScopedForbidDoesNotApplyToComposite now covers the case where a direct backend tool is forbidden while a separately permitted composite remains allowed. docs/authz.md and the vMCP architecture document now explain this behavior, recommend expressing backend restrictions through permit conditions, and call out that composites must be authorized separately by their Tool entity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support backend-scoped Cedar authorization in VirtualMCPServer

3 participants