stacklok / stacklok/toolhive

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

Open
#6,511 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

needs-triage
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

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

  • A delegated token under a pinned primaryUpstreamProvider authorizes against a policy keyed on an upstream attribute, matching the decision the subject's own token would receive for the same action.
  • A conformance test pinning the attenuation invariant: a subject token with broader upstream authority, exchanged, must not let Cedar reach authority available only to the subject. Requested in https://github.com/stacklok/toolhive/issues/6424#issuecomment-5401328084.
  • tsid remains 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.md under "Tokens with no upstream login".

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with docs/authz.md under “Tokens with no upstream login,” then trace the RFC 8693 exchange handler and the existing grantScopes and computeLifetime paths. Define the transcription and claim-source behavior before implementation, and verify the acceptance cases: upstream-attribute authorization, attenuation, and continued tsid severing.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
authorization, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.