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_workloadruns an RFC 6749client_credentialsgrant with the gateway’s ownclient_idand secret, through the sameworkday-oauthplugin, reading no caller token at all. Thesearch_reposroute has noauthentication: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#
- Swap the subjects. Put
subject: this_workloadonget_compensationand re-run the anonymous case. Expect: it now succeeds, because the gateway no longer needs the caller’s token. - Ask for a caller that isn’t there. Add
authentication: [keycloak]tosearch_reposand re-run it anonymously. Expect: the denial moves earlier, to identity resolution, even though the delegation itself never needed a caller. - Drop the audience mapper (advanced).
subject: this_workloadrelies on thecpex-gatewayclient being a service account with an audience mapper forgithub-api(seeidp/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?
Is the mode declared or derived?
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 usersub: 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.