Skip to content

fix: name which term of the delegation intersection came out empty - #356

Open
KunalJavelin wants to merge 2 commits into
mainfrom
fix/name-the-empty-delegation-term-302
Open

KunalJavelin wants to merge 2 commits into
mainfrom
fix/name-the-empty-delegation-term-302

Conversation

@KunalJavelin

@KunalJavelin KunalJavelin commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #302. Also closes ask 3 of highflame-ai/highflame-authn#181.

The problem

A delegated grant is a three-way intersection:

requested  ∩  the subject token's own scopes  ∩  the actor's ceiling

When it came out empty, tokenExchange returned one string for all three causes:

return nil, oauthBadRequest(oautherror.InvalidScope, "requested scopes are not available for delegation")

The three need opposite repairs:

Cause What the caller must do
Requested nothing name the scopes — this grant has no RFC 6749 §3.3 default
The subject does not hold it widen the delegator, or delegate from a different identity
The actor ceiling excludes it widen the sub-agent's allowed_scopes

One message for all three sends people to widen the identity that was never the constraint. highflame-ai/highflame-authn#181 reports losing time to two of them in a row.

What the caller sees now

omitted        -> ...: no scopes were requested, and this grant has no default — name the scopes to delegate
subject        -> ...: the subject token does not hold [data:read]
actor policy   -> ...: the actor's credential policy does not permit [data:read]
actor registry -> ...: the actor identity is not registered for [data:read]
both           -> ...: the subject token does not hold [order:write]; the actor's credential policy does not permit [data:read]

The actor term splits in two because effectiveAllowedScopes is either/or, not layered: when the actor's credential policy sets scopes, the identity's own allowed_scopes is never read. Naming the registration when the ceiling came from the policy sends the caller to edit a field that was not the constraint — the exact wrong-repair hint this PR exists to remove.

effectiveAllowedScopesWithSource returns the ceiling and its source, and effectiveAllowedScopes is now a thin wrapper over it. One decision point, so a caller cannot re-derive "which layer won" and go stale — and it is due to change, since #300's fix makes the resolution layered rather than either/or.

Behaviour of effectiveAllowedScopes is unchanged. It returned a nil-or-empty identity slice before and returns nil now; every caller gates on len().

All three sets were already in hand at the denial site, so naming the empty term costs no extra lookup.

Four design decisions

The wire contract does not change. The code stays invalid_scope, and the original sentence stays as a prefix, so an existing log grep keeps matching. Nothing in the repo asserted the old string.

An actor with no ceiling is never blamed. An empty ceiling means "no restriction from this layer". Blaming it would send the caller to widen a registration that was never the constraint.

Each scope is blamed once. A scope the subject cannot delegate is reported under the subject even when the actor also lacks it. Listing it twice would read as two separate repairs.

The omitted-scope case is called out explicitly. token_exchange is the one grant with no RFC 6749 §3.3 default. Every other grant teaches the caller that omitting scope means "grant the full ceiling", so the failure is genuinely surprising.

Tests

  • Six unit tests cover delegationScopeDenial in isolation, including the two cases that are easy to get wrong: an unrestricted actor, and a scope both parties lack.
  • Three integration subtests cover that tokenExchange hands the helper the right sets, including one per actor-ceiling source. The unit tests alone would not have caught a wiring mistake.
  • TestEffectiveAllowedScopesWithSource pins that the reported source never disagrees with the scopes returned.
  • The existing TestSubagentDelegation_EmptyRequestGrantsNothing gains a description assertion.

All three integration assertions were run against the old single message and fail:

"requested scopes are not available for delegation" does not contain "no scopes were requested"
"requested scopes are not available for delegation" does not contain "the subject token does not hold [tools:execute]"
"requested scopes are not available for delegation" does not contain "the actor identity is not registered for [tools:read]"

Forcing the old single actor wording fails the policy subtest too:

"...the actor identity is not registered for [tools:read]" does not contain
"the actor's credential policy does not permit [tools:read]"

That subtest is how the imprecision was found: delegationPolicy() sets allowed_scopes, so its ceiling came from the policy, and the first pass asserted a message naming the wrong field.

Full suite green: unit, plus 39s of integration. go vet and gofmt clean.

Not in scope

The self-mint denial from #341 and #345. requireGrantableScope answers api_key, client_credentials, jwt_bearer and authorization_code with one string — "requested scopes are not permitted for this identity" — for a chain of up to four ceilings (key.scopes, the key's policy, the identity's policy, the deprecated identity list). Same defect class as this PR, different grant, and naming the layer there means threading the individual ceilings into that helper. Worth its own issue.

The scope-ceiling semantics. #300 (the tenant default policy sets no scope ceiling) and #301 (an empty ceiling means "no restriction") are unchanged here. This PR only improves the message once the intersection is already empty.

🤖 Generated with Claude Code

KunalJavelin and others added 2 commits September 15, 2026 22:25
A delegated grant is a three-way intersection:

  requested ∩ the subject token's own scopes ∩ the actor's ceiling

When it came out empty, tokenExchange returned one string for all three
causes — and the three need OPPOSITE repairs. The caller either names the
scopes, widens the DELEGATOR, or widens the SUB-AGENT. One message for all
three sends people to widen the identity that was never the constraint;
highflame-authn#181 reports losing time to two of them in a row.

All three sets are already in hand at the denial site, so naming the empty
term costs no extra lookup.

  omitted -> ...: no scopes were requested, and this grant has no default
                  — name the scopes to delegate
  subject -> ...: the subject token does not hold [data:read]
  actor   -> ...: the actor identity is not registered for [data:read]
  both    -> ...: the subject token does not hold [order:write]; the actor
                  identity is not registered for [data:read]

Design notes:

- The error code stays invalid_scope and the original sentence stays as a
  prefix, so neither the wire contract nor an existing log grep changes.
  Nothing in the repo asserted the old string.
- An actor with NO ceiling is never blamed. An empty ceiling means "no
  restriction from this layer", so blaming it would send the caller to widen
  a registration that was never the constraint.
- Each scope is blamed once. A scope the subject cannot delegate is reported
  under the subject even when the actor also lacks it; listing it twice would
  read as two separate repairs.
- The omitted-scope case is called out explicitly because token_exchange is
  the ONE grant with no RFC 6749 §3.3 default — every other grant teaches the
  caller that omitting scope means "grant the full ceiling".

Tests: six unit tests cover delegationScopeDenial in isolation; two
integration subtests cover that tokenExchange hands it the right three sets.
All three integration assertions fail against the old single message and pass
with this change. Full suite green — unit plus 39s of integration.

Fixes #302

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass always said "the actor identity is not registered for [x]".
effectiveAllowedScopes is either/or, not layered: when the actor's credential
policy sets scopes, the identity's own allowed_scopes is never read. So the
ceiling frequently came from the POLICY while the message pointed at the
REGISTRATION — the wrong-repair hint this denial exists to remove.

The existing integration subtest proved it. delegationPolicy() sets
allowed_scopes, so that test's ceiling came from the policy, and the old
assertion passed on a message that named the wrong field.

  policy ceiling   -> the actor's credential policy does not permit [tools:read]
  registration     -> the actor identity is not registered for [tools:read]

effectiveAllowedScopesWithSource returns the ceiling AND its source, and
effectiveAllowedScopes is now a thin wrapper over it. One decision point: a
caller that re-derived "which layer won" separately would go stale the moment
that precedence changes — and it is due to change, since #300's fix makes the
resolution layered rather than either/or.

Behaviour of effectiveAllowedScopes is unchanged. It returned a nil-or-empty
identity slice before and returns nil now; every caller gates on len().

Tests: one new unit test per source, plus TestEffectiveAllowedScopesWithSource
pinning that the reported source never disagrees with the scopes returned. The
integration subtest for a policy ceiling joins the one for a registration
ceiling, so both branches are covered end to end. Forcing the old single
wording fails the policy subtest:

  "...the actor identity is not registered for [tools:read]" does not contain
  "the actor's credential policy does not permit [tools:read]"

Full suite green — unit plus 37s of integration. go vet and gofmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

invalid_scope on delegation cannot say which of the three intersection terms was empty

2 participants