diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index e370653628..e66652a665 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -616,10 +616,20 @@ func (a *Authorizer) resolveClaims(identity *auth.Identity) (jwt.MapClaims, erro // Embedded auth server path: the upstream IDP token is the primary claim source. upstreamToken, tokenFound := identity.UpstreamTokens[a.primaryUpstreamProvider] if !tokenFound || upstreamToken == "" { - // The upstream token must be present if the authorizer is configured to use it. - // Missing token means the session has no upstream credential; deny. - return nil, fmt.Errorf("upstream token for provider %q not found in identity", - a.primaryUpstreamProvider) + // RFC 8693 delegated tokens (and any token without an upstream session) + // have no UpstreamTokens entry — that binding only exists for an + // identity established via the upstream browser flow. Falling back to + // the request token's claims keeps delegation usable when Cedar is + // configured with a primaryUpstreamProvider (auto-derived from the + // sole upstream), rather than denying every tool before policy + // evaluation. This mirrors the existing opaque-token fallback branch + // below. See #6424. + a.claimKeyLog.Do(func() { + slog.Warn("upstream token not found for provider; falling back to request-token claims for Cedar evaluation", + "provider", a.primaryUpstreamProvider) + }) + a.logClaimKeys("delegated-fallback", requestClaims) + return requestClaims, nil } upstreamClaims, err := parseUpstreamJWTClaims(upstreamToken) diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index bca48b8788..13e7a25d29 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -1404,8 +1404,12 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { }, UpstreamTokens: map[string]string{}, }, - wantErr: true, - errContains: "upstream token for provider", + // Delegated tokens (RFC 8693) have no UpstreamTokens entry; the + // authorizer now falls back to request-token claims instead of + // erroring. Here the fallback sub does not match the policy, so + // authorization is denied by policy evaluation, not by a missing- + // token error. See #6424. + wantAuthorize: false, }, { name: "upstream_token_opaque_falls_back_to_request_claims_denied", @@ -1469,8 +1473,29 @@ func TestAuthorizeWithJWTClaims_UpstreamProvider(t *testing.T) { }, UpstreamTokens: nil, }, - wantErr: true, - errContains: "upstream token for provider", + // Same fallback as above: a nil UpstreamTokens map (no tsid, as in + // a delegated token minted without an IDP session link) falls back + // to request claims rather than erroring. Policy evaluation then + // denies because sub mismatch. + wantAuthorize: false, + }, + { + name: "delegated_token_missing_upstream_falls_back_and_permits_via_request_claims", + identity: &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "upstream-user", + Claims: map[string]any{ + "sub": "upstream-user", + "act": map[string]interface{}{"sub": "spiffe://toolhive.dev/ns/agents/sa/delegate"}, + }, + }, + UpstreamTokens: nil, + }, + // RFC 8693 delegated token has no upstream session (no tsid) and + // thus no UpstreamTokens entry. The fallback to request claims + // carries the delegated subject through, so a policy targeting the + // user still permits. See #6424. + wantAuthorize: true, }, { name: "upstream_token_has_no_sub_claim",