Skip to content

Add backend-scoped vMCP authorization - #6487

Merged
ChrisJBurns merged 3 commits into
stacklok:mainfrom
Sanskarzz:ceder-auth-vmcp
Oct 26, 2026
Merged

ChrisJBurns merged 3 commits into
stacklok:mainfrom
Sanskarzz:ceder-auth-vmcp

Conversation

@Sanskarzz

@Sanskarzz Sanskarzz commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

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 80.04%. Comparing base (18e6be6) to head (8cf986d).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6487      +/-   ##
==========================================
- Coverage   80.07%   80.04%   -0.04%     
==========================================
  Files         838      839       +1     
  Lines       85299    85318      +19     
==========================================
- Hits        68307    68290      -17     
- Misses      16987    17023      +36     
  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 thread pkg/authz/authorizers/cedar/core.go Outdated
Comment thread pkg/authz/authorizers/cedar/entity_test.go Outdated
Comment thread pkg/authz/authorizers/cedar/entity.go Outdated
Comment thread pkg/authz/authorizers/resource_metadata.go
Comment thread pkg/vmcp/core/admission.go Outdated
@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
Collaborator 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.

@arian-gogani

Copy link
Copy Markdown

Thanks for checking it and making the behavior explicit. The new forbid regression and the documentation answer the question I raised: composites are outside Backend scope, backend-scoped forbids do not follow their internal steps, and composite Tool entities need separate authorization.

That is a defensible boundary now that policy authors can see it and the test pins it. My original reading is resolved.

@aponcedeleonch aponcedeleonch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @Sanskarzz, every point from the review is addressed in c1504ce, and the composite forbid case is now pinned by a test and documented. Approving.

One thing before this can merge: the branch is quite far behind main, so please rebase it to get a fresh CI run on the current base.

@Sanskarzz

Copy link
Copy Markdown
Collaborator Author

Thanks, @aponcedeleonch, for reviewing. Now CI is green; the PR is ready to merge

@ChrisJBurns
ChrisJBurns dismissed amirejaz’s stale review October 26, 2026 16:08

Comments have been addressed!

@ChrisJBurns
ChrisJBurns merged commit 60f0184 into stacklok:main Oct 26, 2026
44 checks passed
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

5 participants