Let Cedar read user claims the upstream asserts only in its ID token - #6054
Open
JAORMX wants to merge 2 commits into
Open
Let Cedar read user claims the upstream asserts only in its ID token#6054JAORMX wants to merge 2 commits into
JAORMX wants to merge 2 commits into
Conversation
JAORMX
requested review from
ChrisJBurns,
blkt,
jerm-dro,
jhrozek,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
July 27, 2026 19:46
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6054 +/- ##
==========================================
+ Coverage 72.26% 72.29% +0.03%
==========================================
Files 722 724 +2
Lines 75138 75346 +208
==========================================
+ Hits 54296 54473 +177
+ Misses 16980 16972 -8
- Partials 3862 3901 +39 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
11 tasks
Cedar read group and role claims from the pinned upstream's access token only, so a provider that asserts group membership in its id_token — common where groups are a profile-scope claim — produced an empty group set and every `principal in THVGroup::"..."` policy denied for every user. #6022 fixed the same shape for `email` but deliberately left groups out, because widening the supplement newly grants requests that deny. This is that change. Provenance is what makes it safe: since #6036 the supplement source is the same provider's id_token, so a group read from it is asserted by the same IdP that issued the access token. The supplement is inverted from an allow-list of {name, email} to a deny-list of claims that describe the id_token rather than the user (registered/OIDC token claims) plus `sub`, which is the Cedar principal. An allow-list cannot work here: the group claim's name is chosen by the deployment via GroupClaimName/RoleClaimName or by the IdP as a URI-style name, so any fixed list denies silently for names ToolHive does not choose — the defect shape of #5916 and #6049 both. Inverting also settles `hd` and custom namespaced claims in the same pass. Precedence is unchanged and stays fill-only rather than union: an access token asserting a partial group list keeps exactly that list, because per-audience claim filtering means that list is the provider's answer for this resource server. The ToolHive-issued token is still never a claim source, so a group on it still grants nothing. Fixes #6049 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
Group and role claims are facts about the user, so either of the pinned provider's tokens may report them. `scope`/`scp` are not: they assert the authority a particular token carries, and the only authority at issue is the presented access token's. Where a provider emits `scope` in an id_token at all — non-standard — it may echo what the client requested rather than what the user granted, and granular consent makes those differ routinely, so supplementing it could satisfy a scope gate on authority the access token does not carry. Excluding them by name is effective rather than cosmetic, because unlike a group claim name these are standardised (RFC 8693/9068 `scope`, Entra `scp`). The argument that retired the allow-list for groups does not transfer. Also records the shortest form of the deny-list argument: the access-token branch already admits every claim with no allow-list, so an allow-list on the id_token alone was an asymmetry between two tokens at the same trust level, and inverting it restores parity rather than widening a boundary. Promotes the group-revocation window to a callout in the operator guide. Groups sourced from an id_token are login-time facts, so revocation may not propagate until the session ends (7d default) rather than within an access-token lifetime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
JAORMX
force-pushed
the
feat-cedar-id-token-group-claims-6049
branch
from
July 28, 2026 07:15
4485b74 to
82c4f57
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cedar read group and role claims from the pinned upstream's access token only, so a
provider that asserts group membership in its
id_tokenproduced an empty group setand every
principal in THVGroup::"..."policy denied for every user, with nodiagnostic beyond the
hasguard failing. Same shape as #5916 — a claim the upstreamdoes assert, in a token ToolHive already holds, that the authorizer never looked at.
profileClaimsFromIDToken = {name, email}is replaced byidTokenClaimsNeverSupplemented, a closed spec-derived deny-list; every other claimthe pinned provider's
id_tokenasserts is now admitted, still fill-only (the accesstoken always wins).
Fixes #6049
Two things need a maintainer decision rather than my say-so — please read
"Special notes" before approving: this is wider than the issue scoped (an allow-list
became a deny-list), and it makes group-revocation latency session-scoped for
id_token-sourced groups.
Type of change
Test plan
task test)task lint-fix)task test-e2e)task lint-fix0 issues; codespell clean over all 5 changed files;go test -race -count=1 ./pkg/authz/... ./pkg/auth/...green;task testshows onlythree failures that are pre-existing sandbox limitations (
pkg/apiTestNewServer_ReadTimeoutConfiguredandTestSecurityHeaders— no container runtime;pkg/secrets/keyringTestCompositeProvider_RealProviders— no keyring).Two negative controls, because tests for a change that grants access have to prove
the grant rather than describe it:
{name, email}semantics fails 8 subtests,including both grant paths. Critically, the three security invariants pass
identically in both configurations — so they bound the boundary rather than
merely exercising the new code:
toolhive_groups_ignored_when_upstream_configured,direct_groups_ignored_when_upstream_configured,id_token_registered_claims_are_never_filled_or_overwritten, andTestAuthorizeWithJWTClaims_PrincipalStaysUpstreamSourced."scope": {}, "scp": {}deny-list line fails exactly threesubtests, so that exclusion is load-bearing rather than decorative.
Does this introduce a user-facing change?
Yes — it grants access that is currently denied. Only where
primaryUpstreamProvideris set, the pinned provider has a stored
id_token, and the claim is one its accesstoken omits entirely:
THVGroup/GroupEntityTypepolicy because the providerasserts groups in the id_token only —
tools/listgoes from empty to populated,tools/callfrom 403 to 200. The widest case, and the reported one.RoleClaimName,cognito:groups, and URI-style group claims.has-guarded gates onhd,email_verified,acr/amr/auth_timeand customuser claims the provider puts in the id_token only.
Unchanged:
primaryUpstreamProviderempty; the opaque-token branch; any claim theaccess token already asserts (fill-only); providers with no stored id_token; group,
role and scope claims on the ToolHive-issued token (still grant nothing); the
principal; and the 15 token-describing claims. One direction can tighten rather than
loosen — a
forbid/unlesspolicy keyed on a claim being absent would now denywhere the id_token supplies it; no realistic example was constructible, since
unlessgates in this codebase test presence, but it belongs in the release note.
Special notes for reviewers
1. The scope expansion — why an allow-list was replaced rather than extended. An
allow-list provably cannot work here: the claim carrying group membership is named by
the deployment (
GroupClaimName/RoleClaimName) or by the IdP (Auth0/Okta URI-stylenames), so any fixed list denies silently for names ToolHive does not choose — the
defect shape of both #5916 and #6049. Config-derived would cover the two configured
names and still miss the well-known defaults' relatives,
hd, and per-tenantorganization claims, guaranteeing a third round.
The shorter argument for why a deny-list is safe:
resolveClaimsalready admitsevery claim of the upstream access token with no allow-list at all. An allow-list
applied to the id_token alone was therefore an asymmetry between two tokens at the
same trust level from the same provider — not a boundary being held. Inverting restores
parity rather than widening a trust boundary.
2. Precedence is fill-only, deliberately not union. Union looked attractive for
set-valued claims and is unsafe in the direction that matters: per-application claim
filtering (Okta group filters, Entra group-claim config) is applied when a token is
minted for a given audience, so the access token's group list is the provider's answer
to "which of this user's groups are relevant to this resource server". Unioning the
id_token's list — minted for ToolHive's own auth-server client — would restore groups
the provider deliberately withheld from this audience. Fill-only can only ever supply
a claim the provider left entirely absent; it can never contradict or extend one.
3.
scope/scpare excluded, and this is the one place parity does not hold.Parity between the two tokens holds for claims about the user; a claim asserting
what a token may do is not symmetric between them. Concretely, granular and
incremental consent (Google granular permissions, Entra incremental consent) make
requested-minus-granted the normal case, and an id_token is minted from the
authorization request: client asks for
Mail.ReadWrite, user grants onlyMail.Read, the access token carries the narrower grant, and supplementing would leta
claimset_scp.containsAny("Mail.ReadWrite")gate match authority the userexplicitly declined. Note this reverses an earlier position in this series — a prior
test named
scopealongsidegroupsas authorization-bearing — so it is a deliberatechange of stance, and
TestAuthorizeWithJWTClaims_ScopeStaysAccessTokenSourcedincludes the downscoped-consent case.
4. Group revocation latency becomes session-scoped for id_token-sourced groups —
⚠️ callout in the operator guide rather
this is the trade-off to weigh. Access tokens are refreshed, so a group revoked
upstream propagates within an access-token lifetime. A stored id_token is replaced only
when a refresh happens to rotate one, and is deliberately used past
exp(seeIdentity.UpstreamIDTokens), so a revoked group can keep granting until the sessionends —
refreshTokenLifespan, 7 days by default. Enforcingexpis not the remedy: itwould flip permit→deny mid-session for every user, not only the revoked one. The
remedies are to assert groups in the access token, or shorten
refreshTokenLifespan.The affected deployments currently deny everything, so this is
broken→works-with-stale-groups rather than a regression — but it is a real change in
security posture and the call is yours. It is a
than prose.
This also corrects a claim I wrote in #6036:
Identity.UpstreamIDTokenssaid theid_token's claims are "no staler than the alternative, since the profile claims
mirrored into the ToolHive-issued token are likewise captured once at login". True for
email; false for groups, because there is no issued-token alternative for those.Interaction with #6052 (issue #6048): both touch
resolveClaims, but the only edithere is its doc comment — the
!looksLikeJWTbranch body is byte-identical to main —so whichever lands second needs a comment-block rebase only. If #6052 lands first, its
opaque branch inherits this deny-list automatically, which is the intended coupling:
both branches widen together as one release decision.
Generated with Claude Code