|
| 1 | +--- |
| 2 | +title: "Delegation subjects" |
| 3 | +weight: 13 |
| 4 | +--- |
| 5 | + |
| 6 | +# Module 12: Delegation subjects (who the call speaks for) |
| 7 | + |
| 8 | +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. |
| 9 | +> |
| 10 | +> **Cookbook recipes:** [Recipe 1: on-behalf-of a user]({{< relref "/docs/identity-delegation#recipe-1-user-acting-through-an-agent-on-behalf-of" >}}) (`subject: user`) and [Recipe 3: a service acting as itself]({{< relref "/docs/identity-delegation#recipe-3-a-service-acting-as-itself" >}}) (`subject: this_workload`). |
| 11 | +
|
| 12 | +**Goal:** choose *whose* authority a minted downstream token carries: the caller's, or the gateway's own. |
| 13 | + |
| 14 | +## The problem |
| 15 | + |
| 16 | +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. |
| 17 | + |
| 18 | +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. |
| 19 | + |
| 20 | +## Build it |
| 21 | + |
| 22 | +Two routes, two subjects. From [`policies/m12.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m12.yaml): |
| 23 | + |
| 24 | +```yaml |
| 25 | +routes: |
| 26 | + # On behalf of the caller: exchange the CALLER's token. |
| 27 | + - tool: get_compensation |
| 28 | + authentication: [keycloak] |
| 29 | + authorization: |
| 30 | + pre_invocation: |
| 31 | + - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)" |
| 32 | + - "require(delegation.granted)" |
| 33 | + |
| 34 | + # As the gateway itself: mint via the gateway's own client credentials. |
| 35 | + - tool: search_repos |
| 36 | + authorization: |
| 37 | + pre_invocation: |
| 38 | + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: this_workload)" |
| 39 | + - "require(delegation.granted)" |
| 40 | +``` |
| 41 | +
|
| 42 | +- **`subject: user`** (the default) runs an RFC 8693 token exchange on the caller's inbound token. No caller token, nothing to exchange. |
| 43 | +- **`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. |
| 44 | + |
| 45 | +## Run it |
| 46 | + |
| 47 | +```bash |
| 48 | +cargo run -p cpex-tutorial --example m12_subjects |
| 49 | +``` |
| 50 | + |
| 51 | +``` |
| 52 | +▸ alice → get_compensation (subject: user, exchanges alice's token) |
| 53 | + ✓ ALLOWED { ... } |
| 54 | + |
| 55 | +▸ anonymous → get_compensation (subject: user, no token to exchange, delegation fails) |
| 56 | + ✗ DENIED [delegation.bad_request] ... empty bearer_token ... |
| 57 | + |
| 58 | +▸ anonymous → search_repos (subject: this_workload, gateway mints via client_credentials) |
| 59 | + ✓ ALLOWED { ... } |
| 60 | + |
| 61 | +▸ alice → search_repos (subject: this_workload, same result: the caller's identity is not used) |
| 62 | + ✓ ALLOWED { ... } |
| 63 | +``` |
| 64 | +
|
| 65 | +`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. |
| 66 | +
|
| 67 | +## Try it |
| 68 | +
|
| 69 | +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. |
| 70 | +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. |
| 71 | +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`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). Remove that mapper and the minted token won't carry the audience. |
| 72 | +
|
| 73 | +## Checkpoint |
| 74 | +
|
| 75 | +{{< details "Why does anonymous fail on one route but not the other?" >}} |
| 76 | +`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. |
| 77 | +{{< /details >}} |
| 78 | +
|
| 79 | +{{< details "Is the mode declared or derived?" >}} |
| 80 | +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. |
| 81 | +{{< /details >}} |
| 82 | +
|
| 83 | +## Go deeper |
| 84 | +
|
| 85 | +The same rule covers the other subjects, each with its own module: |
| 86 | +
|
| 87 | +- **`subject: client`**, the calling OAuth client acting as itself, its own token scoped down: [module 13]({{< relref "13-client" >}}). |
| 88 | +- **`subject: caller_workload`**, the calling agent proving itself with a SPIFFE JWT-SVID, exchanged in two legs: [module 16]({{< relref "16-workload" >}}), which needs the SPIRE overlay. |
| 89 | +- **`actor:`**, recording the calling agent alongside the user `sub`: [module 15]({{< relref "15-dual-principal" >}}). |
| 90 | +
|
| 91 | +See the [Identity & Delegation cookbook]({{< relref "/docs/identity-delegation" >}}) for the reference recipes, and the [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` / `actor:` contract. |
| 92 | +
|
| 93 | +## Next |
| 94 | +
|
| 95 | +[Module 13: Delegation as a client]({{< relref "13-client" >}}): scope a token an agent minted for *itself*, when the caller is an OAuth client rather than a user. |
0 commit comments