Skip to content

Carry upstream claims across RFC 8693 exchange so delegated tokens work with attribute-based Cedar policies #6511

Description

@jhrozek

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".

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageIssue needs initial triage by a maintainer

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions