Skip to content

Enforce Cedar policies on upstream IDP token claims - #4448

Merged
tgrunnagle merged 8 commits into
mainfrom
issue_4408_cedar-on-upstream
Apr 1, 2026
Merged

tgrunnagle merged 8 commits into
mainfrom
issue_4408_cedar-on-upstream

Conversation

@tgrunnagle

@tgrunnagle tgrunnagle commented Mar 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Cedar policies could not reference upstream IDP claims (e.g. GitHub login, Okta groups) because the Cedar authorizer always evaluated against ToolHive-issued JWT claims, while upstream tokens stored in identity.UpstreamTokens were never read.
  • Two additional gaps made group-based policies entirely non-functional: Identity.Groups was never populated despite a code comment saying authorization logic "MUST" do this extraction, and CreatePrincipalEntity always produced empty Parents sets, making principal in THVGroup::"engineering" always evaluate to false.
  • This PR wires all three gaps: adds a PrimaryUpstreamProvider config option to the Cedar authorizer so it can read upstream IDP token claims, adds ExtractGroupsFromClaims to populate groups from those claims, and updates CreatePrincipalEntity/CreateEntitiesForRequest to build the THVGroup parent entity hierarchy so Cedar's in operator works for group membership.

Closes #4408

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

Changes

File Change
pkg/auth/identity.go Add ExtractGroupsFromClaims with configurable claim name and well-known defaults (groups, roles, cognito:groups)
pkg/authz/authorizers/cedar/core.go Add PrimaryUpstreamProvider and GroupClaimName to ConfigOptions/Authorizer; add parseUpstreamJWTClaims; add InjectUpstreamProvider; branch in AuthorizeWithJWTClaims to resolve claims from upstream token when configured; pass extracted groups into all authorize sub-methods
pkg/authz/authorizers/cedar/entity.go Update CreatePrincipalEntity to accept a groups slice and create THVGroup parent entities; update CreateEntitiesForRequest to accept and forward groups
pkg/runner/middleware.go Add injectUpstreamProviderIfNeeded helper; call it when building the authz middleware config so Cedar uses upstream IDP claims whenever the embedded auth server is active
pkg/runner/config_builder.go Propagate embeddedAuthServerCfg into addAuthzMiddleware so the Kubernetes operator code path also injects the upstream provider name
pkg/vmcp/config/config.go Add PrimaryUpstreamProvider field to AuthzConfig
pkg/vmcp/auth/factory/incoming.go Forward PrimaryUpstreamProvider from vMCP AuthzConfig into cedar.ConfigOptions

Does this introduce a user-facing change?

Operators using Cedar policies with the embedded auth server can now write policies that reference upstream IDP claims (e.g. a GitHub login or Okta groups claim) by setting primary_upstream_provider in the Cedar config options. Group-based policies using principal in THVGroup::"<group-name>" now evaluate correctly when the upstream token carries a standard group claim.

Special notes for reviewers

The upstream token's JWT signature is intentionally not re-verified. The token was already validated by the IDP during the OAuth 2.0 code exchange; re-verification would require a round-trip to the IDP's JWKS endpoint on every authorization call and provides no security benefit in this trust model. If the upstream token is an opaque (non-JWT) token rather than a JWT access token, the authorizer returns an error and denies the request — there is no silent fallback to ToolHive claims, which is deliberate to prevent misconfigured deployments from authorizing against unintended claims.

The InjectUpstreamProvider function in core.go is a no-op for non-Cedar authorizer configs, making it safe to call unconditionally from the runner middleware without needing to know the authorizer type ahead of time.

Large PR Justification

  • Closes singular github issue tracking Cedar policy gaps

Generated with Claude Code

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Mar 30, 2026

@github-actions github-actions Bot 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.

Large PR Detected

This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.

How to unblock this PR:

Add a section to your PR description with the following format:

## Large PR Justification

[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformation

Alternative:

Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.

See our Contributing Guidelines for more details.


This review will be automatically dismissed once you add the justification section.

@codecov

codecov Bot commented Mar 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.60000% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.63%. Comparing base (e69251a) to head (f53a8ef).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
pkg/runner/config_builder.go 57.89% 4 Missing and 4 partials ⚠️
pkg/runner/middleware.go 80.00% 2 Missing and 1 partial ⚠️
pkg/authz/authorizers/cedar/core.go 97.18% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4448      +/-   ##
==========================================
- Coverage   69.64%   69.63%   -0.02%     
==========================================
  Files         491      497       +6     
  Lines       50304    50685     +381     
==========================================
+ Hits        35036    35294     +258     
- Misses      12580    12682     +102     
- Partials     2688     2709      +21     

☔ View full report in Codecov by Sentry.
📢 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.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 30, 2026
@github-actions
github-actions Bot dismissed their stale review March 30, 2026 18:59

Large PR justification has been provided. Thank you!

@github-actions

Copy link
Copy Markdown
Contributor

✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review.

@tgrunnagle
tgrunnagle marked this pull request as ready for review March 30, 2026 19:46
jerm-dro
jerm-dro previously approved these changes Mar 30, 2026
Comment thread pkg/auth/identity.go Outdated
Comment thread pkg/authz/authorizers/cedar/core_test.go
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 31, 2026
tgrunnagle and others added 6 commits March 31, 2026 11:34
Fixed all issues from code review of issue #4408:

- CRITICAL: Add groups []string to authorizePromptGet, authorizeResourceRead,
  and authorizeFeatureList; update CreateEntitiesForRequest call sites to pass
  groups in each. Fix existing test call sites for updated signatures.
- HIGH: Rewrite claims resolution as a true IIFE (direct assignment, not a
  stored function variable) in AuthorizeWithJWTClaims. Populate identity.Groups
  after group extraction. Implement injectUpstreamProviderIfNeeded in
  pkg/runner/middleware.go and call it in PopulateMiddlewareConfigs. Add
  PrimaryUpstreamProvider to vMCP AuthzConfig and propagate it to cedar options
  in the factory.
- MEDIUM: Move ExtractGroupsFromClaims and defaultGroupClaimNames from
  context.go to identity.go. Add GroupClaimName field to cedar.ConfigOptions.
  Document IsAuthorized group entity limitation. Change ExtractGroupsFromClaims
  to accept jwt.MapClaims directly (removes redundant cast).
- Add comprehensive unit tests: ExtractGroupsFromClaims (13 cases),
  parseUpstreamJWTClaims (5), AuthorizeWithJWTClaims upstream-provider path
  (5), group membership Cedar evaluation (3), identity.Groups population (1),
  custom group claim name (1), InjectUpstreamProvider (3),
  CreatePrincipalEntity with groups (4), CreateEntitiesForRequest with groups
  (3), injectUpstreamProviderIfNeeded (5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixed issues from second round of code review:
- HIGH: Remove identity.Groups mutation from AuthorizeWithJWTClaims
  to respect the Identity immutability contract after context placement
- HIGH: Update addAuthzMiddleware in config_builder.go to inject the
  upstream provider name, matching the operator path behavior
- MEDIUM: Use authserver.ResolveUpstreamName instead of hardcoded
  "default" literal in addUpstreamSwapMiddleware
- MEDIUM: Make InjectUpstreamProvider a no-op for non-Cedar configs
  rather than returning an error
- MEDIUM: Add TestAddAuthzMiddleware_InjectsUpstreamProvider to cover
  the CLI code path for upstream provider injection
- MEDIUM: Add TestNewCedarAuthzMiddleware_PropagatesPrimaryUpstreamProvider
  to verify PrimaryUpstreamProvider wiring in the vMCP factory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixed issues from third round of code review:
- MEDIUM: Use authserver.ResolveUpstreamName in injectUpstreamProviderIfNeeded
  to eliminate divergent inline normalisation logic
- MEDIUM: Document that InjectUpstreamProvider overwrites any existing
  PrimaryUpstreamProvider — godoc update only
- MEDIUM: Propagate authz.LoadConfig errors in addAuthzMiddleware instead
  of silently falling back to ConfigPath-only mode
- MEDIUM: Add upstream_token_has_no_sub_claim test case covering the
  ErrMissingPrincipal path when upstream JWT has no sub claim
- MEDIUM: Add TestInjectUpstreamProvider_NonCedarPassThrough to verify the
  no-op contract for non-Cedar authorizer types
- MEDIUM: Add group_claim_name_preserved_after_inject test case verifying
  GroupClaimName survives the InjectUpstreamProvider round-trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- move `extractGroupsFromClaims` to `cedar/core.go`
- add more groups tests
@tgrunnagle
tgrunnagle force-pushed the issue_4408_cedar-on-upstream branch from 10c730c to 2ee4cff Compare March 31, 2026 18:36
@github-actions github-actions Bot removed the size/XL Extra large PR: 1000+ lines changed label Mar 31, 2026
@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Mar 31, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 31, 2026
jerm-dro
jerm-dro previously approved these changes Mar 31, 2026
JAORMX
JAORMX previously approved these changes Apr 1, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, left some questions and minor follow-ups.

Comment thread pkg/authz/authorizers/cedar/core.go
Comment thread pkg/authz/authorizers/cedar/core.go
Comment thread pkg/authz/authorizers/cedar/entity.go Outdated
- Move `THVGroup` into constant and document
- Document source of default group claim names
@tgrunnagle
tgrunnagle dismissed stale reviews from JAORMX and jerm-dro via f53a8ef April 1, 2026 15:54
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Apr 1, 2026
@tgrunnagle
tgrunnagle merged commit 5c258a1 into main Apr 1, 2026
44 of 47 checks passed
@tgrunnagle
tgrunnagle deleted the issue_4408_cedar-on-upstream branch April 1, 2026 18:07
tgrunnagle added a commit that referenced this pull request Apr 3, 2026
…embedded auth server is active (#4529)

When a `VirtualMCPServer` uses the embedded authorization server alongside a `token_exchange` outgoing auth strategy, omitting `subjectProviderName` caused the strategy to silently fall back to `identity.Token` (the ToolHive-issued JWT) as the RFC 8693 subject token. The exchange endpoint rejects the ToolHive JWT, but the failure was opaque — nothing in the error indicated that `subjectProviderName` needed to be set. This mirrors the same footgun that was fixed for Cedar authorization policies in #4448 with `injectUpstreamProviderIfNeeded`.

- Added `injectSubjectProviderIfNeeded` to the operator controller (`virtualmcpserver_controller.go`) to auto-populate `SubjectProviderName` on `token_exchange` strategies where it is empty, using the first upstream from `vmcp.Spec.AuthServerConfig` (resolved via `authserver.ResolveUpstreamName`, same logic as Cedar). Applied to both the default strategy and all inline per-backend strategies.
- Added `InjectSubjectProviderNames` to `pkg/vmcp/config/defaults.go` for the YAML config path, so the same defaulting applies when the vMCP binary is run directly with an `authserver-config.yaml` sibling file.
- Called `config.InjectSubjectProviderNames` in `cmd/vmcp/app/commands.go` immediately after loading the auth server config, before the embedded auth server is started.
- Updated the `SubjectProviderName` field comments in `MCPExternalAuthConfig` (`mcpexternalauthconfig_types.go`) and `TokenExchangeConfig` (`pkg/vmcp/auth/types/types.go`) to document the auto-population behavior.
- Regenerated CRD manifests and API docs to reflect the updated field comment.
MatteoManzoni pushed a commit to DocPlanner/toolhive that referenced this pull request Apr 4, 2026
…embedded auth server is active (stacklok#4529)

When a `VirtualMCPServer` uses the embedded authorization server alongside a `token_exchange` outgoing auth strategy, omitting `subjectProviderName` caused the strategy to silently fall back to `identity.Token` (the ToolHive-issued JWT) as the RFC 8693 subject token. The exchange endpoint rejects the ToolHive JWT, but the failure was opaque — nothing in the error indicated that `subjectProviderName` needed to be set. This mirrors the same footgun that was fixed for Cedar authorization policies in stacklok#4448 with `injectUpstreamProviderIfNeeded`.

- Added `injectSubjectProviderIfNeeded` to the operator controller (`virtualmcpserver_controller.go`) to auto-populate `SubjectProviderName` on `token_exchange` strategies where it is empty, using the first upstream from `vmcp.Spec.AuthServerConfig` (resolved via `authserver.ResolveUpstreamName`, same logic as Cedar). Applied to both the default strategy and all inline per-backend strategies.
- Added `InjectSubjectProviderNames` to `pkg/vmcp/config/defaults.go` for the YAML config path, so the same defaulting applies when the vMCP binary is run directly with an `authserver-config.yaml` sibling file.
- Called `config.InjectSubjectProviderNames` in `cmd/vmcp/app/commands.go` immediately after loading the auth server config, before the embedded auth server is started.
- Updated the `SubjectProviderName` field comments in `MCPExternalAuthConfig` (`mcpexternalauthconfig_types.go`) and `TokenExchangeConfig` (`pkg/vmcp/auth/types/types.go`) to document the auto-population behavior.
- Regenerated CRD manifests and API docs to reflect the updated field comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce Cedar Policies on Upstream IDP Token

3 participants