Problem
With a pinned authz.primaryUpstreamProvider, Cedar evaluates policy against claims asserted by that upstream IdP. #6424 fixed the case where an RFC 8693 delegated token was denied outright — it now falls back to the delegated token's own claims, labelled request:no-upstream-session.
That fallback works for policies keyed on the actor, the subject, or the delegated scope:
permit(principal, action == Action::"call_tool", resource) when {
principal.thv_claim_source == "request:no-upstream-session" &&
principal.claim_act.sub == "my-delegate" &&
principal.claim_scope like "*mcp:tools*"
};
It does not work for policies keyed on upstream attributes. A delegated token carries at most sub, act, client_id, scope, name and email. No groups, no roles, no department — nothing the pinned IdP asserted about the subject. A rule guarded on principal has claim_department stops matching, so the request is denied. Same user-visible outcome as the original bug (tools vanish from tools/list), reached at policy evaluation instead of claim resolution.
So delegation under a pinned provider is currently usable only by deployments whose policies are identity-light.
Two secondary effects of the missing attributes:
- A
forbid guarded on an upstream attribute never fires, because its has guard is false. The restriction lifts rather than failing closed.
- With multiple upstreams,
claim_email / claim_name on a delegated token are copied from the subject token and mirror the first configured upstream, which need not be the pinned one.
Both are documented as caveats in docs/authz.md today, but they are workarounds, not a fix.
Proposal: claims transcription
Carry the upstream-asserted attributes across the exchange while keeping the credential severed. This is a named pattern — draft-ietf-oauth-identity-chaining §2.5, whose stated purpose is propagating "user and client identifiers, authorization context, and other relevant information across trust boundaries."
Sketch: at exchange time, the authorization server writes the upstream claims it already holds for the subject's session into the delegated token under one namespaced claim, keyed by provider:
"https://toolhive.dev/upstream_claims": {
"github": { "groups": ["platform"], "department": "engineering" },
"google": { "...": "..." }
}
The resource server already knows its own primaryUpstreamProvider and selects from that map. No Cedar configuration needs to reach the authorization server, so the layering stays intact.
Explicitly out of scope
Do not carry the upstream session pointer (tsid) through the exchange. That would hand the delegate the user's live upstream credential, usable for outbound token injection, bound to the user's session with no agent dimension — so the agent's access could not be revoked independently of the user's. This is contrary to how Entra OBO, AWS AgentCore's token vault, and the ID-JAG / transaction-token drafts all handle the same problem: the actor gets its own audience-scoped artifact, never a replay of the subject's credential. Severing the link is correct and should stay.
Transcription moves attributes, which are facts about the subject. Authority stays bounded by the existing scope intersection in grantScopes.
Open questions
- Opaque upstream access tokens. When the upstream credential is opaque there are no claims to transcribe. This is the existing
request:upstream-opaque path; it needs a decision rather than inheriting one.
- Claim source label. Transcribed claims are not live upstream claims. They likely need their own
thv_claim_source value (something like upstream:<provider>:transcribed) so a policy can distinguish a snapshot from a live assertion.
- Staleness. A transcribed claim is fixed at exchange time and does not follow a refresh. Bounded today by
computeLifetime, which caps the delegated token at the subject token's remaining lifetime — is that bound tight enough?
- Token size. Upstream claim sets can be large. The exchange handler already caps the
act chain at 8KB; transcription needs a comparable bound and a minimization rule for which claims are worth carrying.
- Selection. Transcribe all providers, or let the authorization server be told which subset matters?
Acceptance
Context
Follow-up to #6424 / #6506. The caveats above are documented in docs/authz.md under "Tokens with no upstream login".
Problem
With a pinned
authz.primaryUpstreamProvider, Cedar evaluates policy against claims asserted by that upstream IdP. #6424 fixed the case where an RFC 8693 delegated token was denied outright — it now falls back to the delegated token's own claims, labelledrequest:no-upstream-session.That fallback works for policies keyed on the actor, the subject, or the delegated scope:
It does not work for policies keyed on upstream attributes. A delegated token carries at most
sub,act,client_id,scope,nameandemail. Nogroups, noroles, nodepartment— nothing the pinned IdP asserted about the subject. A rule guarded onprincipal has claim_departmentstops matching, so the request is denied. Same user-visible outcome as the original bug (tools vanish fromtools/list), reached at policy evaluation instead of claim resolution.So delegation under a pinned provider is currently usable only by deployments whose policies are identity-light.
Two secondary effects of the missing attributes:
forbidguarded on an upstream attribute never fires, because itshasguard is false. The restriction lifts rather than failing closed.claim_email/claim_nameon a delegated token are copied from the subject token and mirror the first configured upstream, which need not be the pinned one.Both are documented as caveats in
docs/authz.mdtoday, but they are workarounds, not a fix.Proposal: claims transcription
Carry the upstream-asserted attributes across the exchange while keeping the credential severed. This is a named pattern — draft-ietf-oauth-identity-chaining §2.5, whose stated purpose is propagating "user and client identifiers, authorization context, and other relevant information across trust boundaries."
Sketch: at exchange time, the authorization server writes the upstream claims it already holds for the subject's session into the delegated token under one namespaced claim, keyed by provider:
The resource server already knows its own
primaryUpstreamProviderand selects from that map. No Cedar configuration needs to reach the authorization server, so the layering stays intact.Explicitly out of scope
Do not carry the upstream session pointer (
tsid) through the exchange. That would hand the delegate the user's live upstream credential, usable for outbound token injection, bound to the user's session with no agent dimension — so the agent's access could not be revoked independently of the user's. This is contrary to how Entra OBO, AWS AgentCore's token vault, and the ID-JAG / transaction-token drafts all handle the same problem: the actor gets its own audience-scoped artifact, never a replay of the subject's credential. Severing the link is correct and should stay.Transcription moves attributes, which are facts about the subject. Authority stays bounded by the existing scope intersection in
grantScopes.Open questions
request:upstream-opaquepath; it needs a decision rather than inheriting one.thv_claim_sourcevalue (something likeupstream:<provider>:transcribed) so a policy can distinguish a snapshot from a live assertion.computeLifetime, which caps the delegated token at the subject token's remaining lifetime — is that bound tight enough?actchain at 8KB; transcription needs a comparable bound and a minimization rule for which claims are worth carrying.Acceptance
primaryUpstreamProviderauthorizes against a policy keyed on an upstream attribute, matching the decision the subject's own token would receive for the same action.tsidremains severed; no test relies on the delegate holding the subject's upstream credential.Context
Follow-up to #6424 / #6506. The caveats above are documented in
docs/authz.mdunder "Tokens with no upstream login".