Module 12: Delegation subjects (who the call speaks for)#

You are in the CPEX tutorial. This module needs the IdP.

Cookbook recipes: Recipe 1: on-behalf-of a user (subject: user) and Recipe 3: a service acting as itself (subject: this_workload).

Goal: choose whose authority a minted downstream token carries: the caller’s, or the gateway’s own.

The problem#

Not every downstream call has a user behind it. A scheduled sync, a shared index lookup, an infrastructure call: there the gateway holds the downstream credential and calls as itself, with no caller in the picture. Module 6 minted on behalf of the caller, which is the right default when the agent acts for a signed-in human, and the wrong one here.

The subject: argument on a delegate(...) step picks which principal the minted token speaks for. The delegation mode is derived from it, never declared separately, so a route can’t claim to act on-behalf-of-a-user while actually handing over some other credential.

Build it#

Two routes, two subjects. From policies/m12.yaml:

plugins:
  - name: keycloak
    kind: identity/jwt
    hooks: [identity.resolve]
    capabilities: [perform_http]
    config:
      claim_mapper: standard
      trusted_issuers:
        - issuer: http://localhost:8081/realms/cpex-tutorial
          audiences: [cpex-tutorial]
          algorithms: [RS256]
          decoding_key:
            kind: jwks_url
            url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs
            insecure_http: true
          leeway_seconds: 60

  - name: workday-oauth
    kind: delegator/oauth
    hooks: [token.delegate]
    capabilities: [read_inbound_credentials, write_delegated_tokens, perform_http]
    config:
      token_endpoint: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token
      client_id: cpex-gateway
      client_secret_source:
        kind: literal
        secret: gateway-dev-secret
      insecure_http: true

routes:
  # On behalf of the caller. `subject: user` (the default) exchanges the
  # caller's token, so a request with no token has nothing to exchange and
  # delegation fails at require(delegation.granted).
  - tool: get_compensation
    authentication:
      - keycloak
    authorization:
      pre_invocation:
        - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)"
        - "require(delegation.granted)"

  # As the gateway itself. `subject: this_workload` uses the gateway's own
  # client credentials, so it needs no caller token at all: the enforcement
  # point, not the caller, holds the downstream authority.
  - tool: search_repos
    authorization:
      pre_invocation:
        - "delegate(workday-oauth, target: github-api, audience: github-api, subject: this_workload)"
        - "require(delegation.granted)"

Both plugins declare perform_http: the resolver fetches the JWKS and the delegator calls the token endpoint, each through the HTTP transport the host installs (module 6).

  • subject: user (the default) runs an RFC 8693 token exchange on the caller’s inbound token. No caller token, nothing to exchange.
  • subject: this_workload runs an RFC 6749 client_credentials grant with the gateway’s own client_id and secret, through the same workday-oauth plugin, reading no caller token at all. The search_repos route has no authentication: line, because this path needs no caller.

Run it#

cargo run -p cpex-tutorial --example m12_subjects
▸ alice → get_compensation (subject: user, exchanges alice's token)
  ✓ ALLOWED  { ... }

▸ anonymous → get_compensation (subject: user, no token to exchange, delegation fails)
  ✗ DENIED   [delegation.bad_request] ... empty bearer_token ...

▸ anonymous → search_repos (subject: this_workload, gateway mints via client_credentials)
  ✓ ALLOWED  { ... }

▸ alice → search_repos (subject: this_workload, same result: the caller's identity is not used)
  ✓ ALLOWED  { ... }

subject: user needs a caller: the anonymous request fails at the exchange with an empty token. subject: this_workload needs none, and succeeds anonymously, because the gateway holds the credential.

Try it#

  1. Swap the subjects. Put subject: this_workload on get_compensation and re-run the anonymous case. Expect: it now succeeds, because the gateway no longer needs the caller’s token.
  2. Ask for a caller that isn’t there. Add authentication: [keycloak] to search_repos and re-run it anonymously. Expect: the denial moves earlier, to identity resolution, even though the delegation itself never needed a caller.
  3. Drop the audience mapper (advanced). subject: this_workload relies on the cpex-gateway client being a service account with an audience mapper for github-api (see idp/realm-export.json). Remove that mapper and the minted token won’t carry the audience.

Checkpoint#

Why does anonymous fail on one route but not the other?
`subject: user` exchanges the caller's inbound token; an anonymous request has none, so the exchange fails with `delegation.bad_request`. `subject: this_workload` uses the gateway's own client credentials, so there is nothing about the caller it needs.
Is the mode declared or derived?
Derived. You choose `subject:`, and the delegation mode (on-behalf-of vs. act-as-self) follows from it. There is no separate `mode:` key, so a route can't claim on-behalf-of-user while handing over the gateway's own credential.

Go deeper#

The same rule covers the other subjects, each with its own module:

  • subject: client, the calling OAuth client acting as itself, its own token scoped down: module 13.
  • subject: caller_workload, the calling agent proving itself with a SPIFFE JWT-SVID, exchanged in two legs: module 16, which needs the SPIRE overlay.
  • actor:, recording the calling agent alongside the user sub: module 15.

See the Identity & Delegation cookbook for the reference recipes, and the Delegation reference for the full subject: / actor: contract.

Next#

Module 13: Delegation as a client: scope a token an agent minted for itself, when the caller is an OAuth client rather than a user.