From eccbd4b751ead5e93eb41fa821f73fbf81377d71 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 14 Aug 2026 09:04:40 -0600 Subject: [PATCH 1/7] docs: add new tutorials on identity and static attributes. Signed-off-by: Teryl Taylor --- docs/content/docs/tutorial/06-delegation.md | 2 + docs/content/docs/tutorial/10-testing.md | 2 +- docs/content/docs/tutorial/11-groups.md | 107 +++++++++++++ docs/content/docs/tutorial/12-subjects.md | 95 +++++++++++ docs/content/docs/tutorial/13-client.md | 100 ++++++++++++ docs/content/docs/tutorial/14-passthrough.md | 89 +++++++++++ .../docs/tutorial/15-dual-principal.md | 105 ++++++++++++ docs/content/docs/tutorial/16-workload.md | 118 ++++++++++++++ docs/content/docs/tutorial/17-federation.md | 90 +++++++++++ docs/content/docs/tutorial/18-attributes.md | 107 +++++++++++++ docs/content/docs/tutorial/_index.md | 8 + docs/content/docs/tutorial/capstone.md | 2 +- examples/tutorial/examples/m11_groups.rs | 126 +++++++++++++++ examples/tutorial/examples/m12_subjects.rs | 112 +++++++++++++ examples/tutorial/examples/m13_client.rs | 86 ++++++++++ examples/tutorial/examples/m14_passthrough.rs | 86 ++++++++++ .../tutorial/examples/m15_dual_principal.rs | 114 +++++++++++++ examples/tutorial/examples/m16_workload.rs | 89 +++++++++++ examples/tutorial/examples/m17_federation.rs | 102 ++++++++++++ examples/tutorial/examples/m18_attributes.rs | 83 ++++++++++ .../tutorial/idp/docker-compose.spire.yml | 75 +++++++++ examples/tutorial/idp/docker-compose.yml | 5 + examples/tutorial/idp/partner-export.json | 86 ++++++++++ examples/tutorial/idp/realm-export.json | 26 +++ .../spire/oidc/oidc-discovery-provider.conf | 15 ++ .../tutorial/idp/spire/server/server.conf | 40 +++++ examples/tutorial/idp/spire/setup-spiffe.sh | 108 +++++++++++++ .../policies/attributes/controls.yaml | 17 ++ examples/tutorial/policies/m11.yaml | 64 ++++++++ examples/tutorial/policies/m12.yaml | 69 ++++++++ examples/tutorial/policies/m13.yaml | 65 ++++++++ examples/tutorial/policies/m14.yaml | 52 ++++++ examples/tutorial/policies/m15.yaml | 93 +++++++++++ examples/tutorial/policies/m16.yaml | 66 ++++++++ examples/tutorial/policies/m17.yaml | 50 ++++++ examples/tutorial/policies/m18.yaml | 59 +++++++ examples/tutorial/src/idp.rs | 150 ++++++++++++++++++ examples/tutorial/src/mediate.rs | 45 +++++- 38 files changed, 2699 insertions(+), 9 deletions(-) create mode 100644 docs/content/docs/tutorial/11-groups.md create mode 100644 docs/content/docs/tutorial/12-subjects.md create mode 100644 docs/content/docs/tutorial/13-client.md create mode 100644 docs/content/docs/tutorial/14-passthrough.md create mode 100644 docs/content/docs/tutorial/15-dual-principal.md create mode 100644 docs/content/docs/tutorial/16-workload.md create mode 100644 docs/content/docs/tutorial/17-federation.md create mode 100644 docs/content/docs/tutorial/18-attributes.md create mode 100644 examples/tutorial/examples/m11_groups.rs create mode 100644 examples/tutorial/examples/m12_subjects.rs create mode 100644 examples/tutorial/examples/m13_client.rs create mode 100644 examples/tutorial/examples/m14_passthrough.rs create mode 100644 examples/tutorial/examples/m15_dual_principal.rs create mode 100644 examples/tutorial/examples/m16_workload.rs create mode 100644 examples/tutorial/examples/m17_federation.rs create mode 100644 examples/tutorial/examples/m18_attributes.rs create mode 100644 examples/tutorial/idp/docker-compose.spire.yml create mode 100644 examples/tutorial/idp/partner-export.json create mode 100644 examples/tutorial/idp/spire/oidc/oidc-discovery-provider.conf create mode 100644 examples/tutorial/idp/spire/server/server.conf create mode 100755 examples/tutorial/idp/spire/setup-spiffe.sh create mode 100644 examples/tutorial/policies/attributes/controls.yaml create mode 100644 examples/tutorial/policies/m11.yaml create mode 100644 examples/tutorial/policies/m12.yaml create mode 100644 examples/tutorial/policies/m13.yaml create mode 100644 examples/tutorial/policies/m14.yaml create mode 100644 examples/tutorial/policies/m15.yaml create mode 100644 examples/tutorial/policies/m16.yaml create mode 100644 examples/tutorial/policies/m17.yaml create mode 100644 examples/tutorial/policies/m18.yaml diff --git a/docs/content/docs/tutorial/06-delegation.md b/docs/content/docs/tutorial/06-delegation.md index c1ce7b22..439f1b3b 100644 --- a/docs/content/docs/tutorial/06-delegation.md +++ b/docs/content/docs/tutorial/06-delegation.md @@ -6,6 +6,8 @@ weight: 7 # Module 6: Scoped credentials (Delegation) > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. +> +> **Cookbook recipe:** [Recipe 1 — User acting through an agent (on-behalf-of)]({{< relref "/docs/identity-delegation#recipe-1--user-acting-through-an-agent-on-behalf-of" >}}). **Goal:** mint a narrow, downstream-scoped credential for a call with a real OAuth 2.0 token exchange (RFC 8693), instead of forwarding the caller's full token. diff --git a/docs/content/docs/tutorial/10-testing.md b/docs/content/docs/tutorial/10-testing.md index 78a0785e..43c61679 100644 --- a/docs/content/docs/tutorial/10-testing.md +++ b/docs/content/docs/tutorial/10-testing.md @@ -82,4 +82,4 @@ The `Outcome` from `mediate()`: allowed or denied, and for denials the reason co ## Next -[Capstone: the three-backend agent]({{< relref "capstone" >}}): assemble every control you have built into the full Overview scenario. +[Module 11: Organizing policy]({{< relref "11-groups" >}}): factor the setup your routes share into reusable groups. diff --git a/docs/content/docs/tutorial/11-groups.md b/docs/content/docs/tutorial/11-groups.md new file mode 100644 index 00000000..f2769f6f --- /dev/null +++ b/docs/content/docs/tutorial/11-groups.md @@ -0,0 +1,107 @@ +--- +title: "Organizing policy (Groups)" +weight: 12 +--- + +# Module 11: Organizing policy (Groups) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** factor the setup every route shares — here, the identity resolver — into one reusable **group**, so routes join it instead of repeating it. + +## The problem + +By now you have written `authentication: [keycloak]` on route after route. The resolver is the same everywhere; only the per-route authorization differs. That repetition is a maintenance hazard: add a second issuer or a claim mapper later, and you have to change every route and hope you caught them all. + +A **group** is a named, reusable bundle of policy — authentication steps, authorization steps, plugins — that routes opt into. Put the shared part in a group once; each route joins it and adds only what is specific to it. + +## Build it + +Define a top-level `groups:` section and join it from each route with `groups:`. From [`policies/m11.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m11.yaml): + +```yaml +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: { ... as in module 2 ... } + +# One reusable bundle — the resolver every route needs, written once. +groups: + identified: + authentication: + - keycloak + +routes: + - tool: get_compensation + groups: identified # inherits keycloak; no authentication: line + authorization: + pre_invocation: + - "require(role.hr)" + - tool: search_repos + groups: identified + authorization: + pre_invocation: + - "require(role.engineer)" + - tool: send_email + groups: identified # still resolves the token; no role required + authorization: + pre_invocation: + - "require(authenticated)" +``` + +The `identified` group carries the one thing all three routes share. Each route joins it and adds only its own authorization, so identity is resolved the same way everywhere while policy still decides each outcome per caller. + +Two things worth knowing: + +- **`groups:` is sugar over tags.** `groups: identified` is exactly `meta: { tags: [identified] }` — a route joins a group by naming it. The `groups:` field just makes that membership a first-class, discoverable spelling. Runtime tags a host injects still join groups the same way. +- **An unknown group is a load error.** Join a group that isn't defined — a typo like `groups: identifed` — and the config is rejected at load, so a mistake can't silently leave a route unauthenticated. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m11_groups +``` + +``` +▸ alice (hr) → get_compensation (group resolves her token, require(role.hr) passes) + ✓ ALLOWED { ... } + +▸ evan (engineer) → get_compensation (resolved by the same group, denied at require(role.hr)) + ✗ DENIED [...] access denied + +▸ evan (engineer) → search_repos (same group, require(role.engineer) passes) + ✓ ALLOWED { ... } + +▸ alice (hr) → search_repos (denied at require(role.engineer)) + ✗ DENIED [...] access denied + +▸ alice (hr) → send_email (group resolves her token; require(authenticated) passes) + ✓ ALLOWED { ... } +``` + +Every route resolved the caller's token through the same group, yet each outcome is decided by that route's own authorization. The resolver appears once, not three times. + +## Try it + +1. **Break a join.** Change one route's `groups:` to a name that doesn't exist (`groups: identifed`) and re-run. Expect: the config is rejected at load with an unknown-group error — the typo fails loudly instead of silently dropping authentication. +2. **Change the resolver once.** Add `leeway_seconds: 5` (or a second entry under `trusted_issuers:`) to the `keycloak` plugin. Every route that joins `identified` picks it up — you edited one place, not three. +3. **Tags are the same thing.** Replace `groups: identified` on a route with `meta: { tags: [identified] }` and re-run. Same result — `groups:` and a matching tag are the same membership. + +## Checkpoint + +{{< details "Does the route repeat the group's authentication?" >}} +No. The route has no `authentication:` block, so it inherits the group's. Identity resolution stacks broad → narrow (global → group → route); a route joining `identified` runs the group's `keycloak` resolver without naming it again. +{{< /details >}} + +{{< details "Group or the reserved catch-all?" >}} +`groups:` are opt-in — a route joins one by name. That is different from applying a plugin to *every* request, which is a separate, always-on layer. Reach for a group when a *subset* of routes shares setup, as here. +{{< /details >}} + +## Go deeper + +- [Configuration → Groups]({{< relref "/docs/configuration" >}}) for the full `groups:` schema, defaults, and how membership resolves. + +## Next + +[Module 12: Delegation subjects]({{< relref "12-subjects" >}}): choose whether a minted token speaks for the caller or the gateway itself. diff --git a/docs/content/docs/tutorial/12-subjects.md b/docs/content/docs/tutorial/12-subjects.md new file mode 100644 index 00000000..f5d43980 --- /dev/null +++ b/docs/content/docs/tutorial/12-subjects.md @@ -0,0 +1,95 @@ +--- +title: "Delegation subjects" +weight: 13 +--- + +# Module 12: Delegation subjects (who the call speaks for) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. +> +> **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`). + +**Goal:** choose *whose* authority a minted downstream token carries — the caller, or the gateway itself. + +## The problem + +Module 6 minted a token **on behalf of the caller**: it exchanged the caller's token for a scoped one. That is the right default when the agent acts for a signed-in user. But 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. + +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`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m12.yaml): + +```yaml +routes: + # On behalf of the caller: exchange the CALLER's token. + - 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: mint via the gateway's own client credentials. + - tool: search_repos + authorization: + pre_invocation: + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: this_workload)" + - "require(delegation.granted)" +``` + +- **`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` / secret — the same `workday-oauth` plugin, no caller token read at all. The `search_repos` route deliberately has no `authentication:`, to make the point that this path needs no caller. + +## Run it + +```bash +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 { ... } +``` + +The difference is stark: `subject: user` **needs a caller** — the anonymous request fails at the exchange with an empty token. `subject: this_workload` **needs no caller** — it succeeds anonymously, because the gateway holds the credential. + +## Try it + +1. **Give the anonymous caller a token.** Change the second scenario to send `alice` at `get_compensation`. Expect: it now succeeds — there is a token to exchange. +2. **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. This is exactly the choice `subject:` gives you. +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. + +## Checkpoint + +{{< details "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. +{{< /details >}} + +{{< details "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 deliberately no separate `mode:` key, so a route can't claim on-behalf-of-user while handing over the gateway's own credential. +{{< /details >}} + +## Go deeper + +The two remaining subjects need infrastructure this tutorial's Keycloak doesn't set up, but they follow the same rule — the subject picks the principal: + +- **`subject: client`** — the calling OAuth *client / app* acting as itself (its own token scoped down), rather than a human user. +- **`subject: caller_workload`** — the calling *agent* proving itself with a SPIFFE JWT-SVID, exchanged in two legs (client-assertion, then scope). Needs a SPIFFE issuer (SPIRE). +- **`actor:`** — record the calling agent in the RFC 8693 `act` claim alongside the user `sub`. Whether `act` appears depends on the token service; Keycloak's Standard Token Exchange does not emit it. + +See the [Identity & Delegation cookbook]({{< relref "/docs/identity-delegation" >}}) for recipes covering all of these, and the [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` / `actor:` contract. + +## Next + +[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. diff --git a/docs/content/docs/tutorial/13-client.md b/docs/content/docs/tutorial/13-client.md new file mode 100644 index 00000000..3c393786 --- /dev/null +++ b/docs/content/docs/tutorial/13-client.md @@ -0,0 +1,100 @@ +--- +title: "Delegation as a client" +weight: 14 +--- + +# Module 13: Delegation `subject: client` (the agent scopes its own token) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. +> +> **Cookbook recipe:** [Recipe 5 — Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5--scope-a-token-the-agent-already-holds-1-leg" >}}). + +**Goal:** mint a downstream-scoped token when the caller is not a person but an **agent acting as itself** — an OAuth client that authenticated to the IdP on its own behalf. + +## The problem + +Modules 6 and 12 both assumed a signed-in human: `subject: user` exchanges the *caller's user token*. But plenty of calls have no human behind them and are not the gateway either — an agent authenticates to the IdP as its **own OAuth client** (the `client_credentials` grant) and arrives holding a token that speaks for *itself*. + +You still want least privilege at the boundary: narrow that broad client token to just the tool being called. That is `subject: client`. + +It sits between the two subjects you already know: + +| Subject | Whose token is exchanged? | Needs a caller token? | +|---|---|---| +| `user` (module 6, 12) | the signed-in user's | yes | +| **`client` (this module)** | **the calling agent's own** | **yes** | +| `this_workload` (module 12) | none — gateway mints its own | no | + +Like `subject: user`, it scopes an **inbound** credential — so an anonymous request has nothing to exchange. Unlike `this_workload`, the authority is the *caller's*, not the gateway's. + +## Build it + +Two changes from module 12. First, resolve the agent's token into the **`client`** slot with `role: client`. Second, select it with `subject: client`. From [`policies/m13.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m13.yaml): + +```yaml +plugins: + # role: client routes the inbound token to the `client` slot (not `subject`). + - name: keycloak-agent + kind: identity/jwt + hooks: [identity.resolve] + config: + role: client # <-- the agent as an OAuth client + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-gateway] # the agent's token is aud'd to the gateway + algorithms: [RS256] + decoding_key: { kind: jwks_url, url: "…/certs", insecure_http: true } + +routes: + - tool: search_repos + authentication: [keycloak-agent] + authorization: + pre_invocation: + - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: client)" + - "require(delegation.granted)" +``` + +The agent authenticates upstream as the realm's `cpex-agent` client; CPEX validates that token, and the `delegate` step exchanges it — **one leg** (the scope), because the agent already did the authenticate leg itself. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m13_client +``` + +``` +▸ agent (cpex-agent client) → search_repos (subject: client — scopes the agent's own token) + ✓ ALLOWED { ... "repositories":[ ... ] } + +▸ anonymous → search_repos (subject: client — no client token to exchange, delegation fails) + ✗ DENIED [delegation.bad_request] ... empty bearer_token ... +``` + +The agent's own token was narrowed to the `workday-api` audience and the call went through. The anonymous request had no client token to exchange, so — exactly like `subject: user` — delegation failed. + +## Try it + +1. **Swap to `this_workload`.** Change `subject: client` to `subject: this_workload` and re-run the anonymous case. Expect: it now **succeeds** — the gateway mints from its own credentials and needs no caller token. That is the line between "the agent's authority" and "the gateway's authority." +2. **Give the client the wrong audience.** In `realm-export.json`, remove the `audience-cpex-gateway` mapper from the `cpex-agent` client and restart the IdP. Expect: the exchange fails — Keycloak rejects a subject token whose audience doesn't include the exchanging client (`cpex-gateway`). +3. **Point a user token at it.** A user token also resolves into the `client` slot under `role: client`, so `subject: client` would scope *it* too — the subject follows the slot, not the human/machine distinction. Match `role:` to what actually arrives. + +## Checkpoint + +{{< details "Why does anonymous fail here but succeed under this_workload?" >}} +`subject: client` exchanges the **caller's** client token; an anonymous request has none. `subject: this_workload` uses the **gateway's own** client credentials, so there is no caller token it needs. Both mint a downstream token — the difference is whose authority it carries. +{{< /details >}} + +{{< details "Is this one leg or two?" >}} +One. The agent did the authenticate leg upstream (it got its client token from the IdP itself), so CPEX performs only the **scope** leg — a plain RFC 8693 exchange. The two-leg path is [Recipe 2]({{< relref "/docs/identity-delegation#recipe-2--agent-acting-as-itself-by-its-spiffe-svid" >}}), where the agent presents a SPIFFE SVID and CPEX does both legs. Match the subject to what arrived: a JWT minted from an SVID is a `client`; the SVID itself is a `caller_workload`. +{{< /details >}} + +## Go deeper + +- [Recipe 5 — Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5--scope-a-token-the-agent-already-holds-1-leg" >}}) for the reference version and the SVID-vs-token distinction. +- [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` contract. + +## Next + +[Module 14: Passthrough]({{< relref "14-passthrough" >}}): the opposite move — when to forward the caller's token unchanged instead of minting one. diff --git a/docs/content/docs/tutorial/14-passthrough.md b/docs/content/docs/tutorial/14-passthrough.md new file mode 100644 index 00000000..e0afbbc6 --- /dev/null +++ b/docs/content/docs/tutorial/14-passthrough.md @@ -0,0 +1,89 @@ +--- +title: "Passthrough (forward, don't mint)" +weight: 15 +--- + +# Module 14: Passthrough (forward the caller's token, mint nothing) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. +> +> **Cookbook recipe:** [Recipe 4 — Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4--forward-a-token-the-caller-already-has-passthrough" >}}). + +**Goal:** recognize the case where the right move is to mint *nothing* — validate the caller's token and forward it unchanged. + +## The problem + +Modules 6, 12 and 13 all minted a downstream token with an RFC 8693 exchange. But minting is not always the answer. Sometimes the caller already holds a token that is correct for the downstream — the *agent-brokered* case: the agent authenticated to the IdP itself and handed CPEX a ready token. Exchanging it would be pure overhead. + +**Passthrough** is that case, and it is defined by what is *absent*: there is no `delegate(...)` step. CPEX validates the token inbound, authorizes the call, and the token the caller presented is the token that flows on. + +## Build it + +There is nothing to add — there is something to *leave out*. A plain resolver (as in module 2), a route that authorizes, and **no delegator plugin at all**. From [`policies/m14.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m14.yaml): + +```yaml +plugins: + - name: keycloak # validates the inbound token; that is its whole job + kind: identity/jwt + hooks: [identity.resolve] + config: { role: user, ... as in module 2 ... } + +routes: + - tool: search_repos + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" # no delegate step — the caller's token is forwarded +``` + +Contrast the two shapes directly: + +| Shape | Step | What flows downstream | +|---|---|---| +| **Passthrough** (this module) | *no* `delegate` | the caller's own token, unchanged | +| Mint (modules 6, 13) | `delegate(subject: …)` | a fresh token, scoped to one audience | + +## Run it + +```bash +cargo run -p cpex-tutorial --example m14_passthrough +``` + +``` +▸ alice → search_repos (validated and forwarded — no token minted) + ✓ ALLOWED { ... "repositories":[ ... ] } + +▸ anonymous → search_repos (no token to forward, require(authenticated) denies) + ✗ DENIED [...] access denied +``` + +`alice`'s token was validated and the call went through with no exchange — her token is what would reach the backend. The anonymous caller had no token to forward, so `require(authenticated)` denied it. + +## The trade-off + +Passthrough is the cheapest option and the least contained. A forwarded token carries **whatever scope it was issued with** — as broad as the caller's original. A minted token (modules 6/13) is narrowed to one audience, so a leak downstream is bounded. + +The rule of thumb: **forward only when the caller's token is already scoped for the downstream.** If it is broader than the call needs, mint instead. The choice is a security judgment, and CPEX makes it a one-line difference in policy — a `delegate` step, or none. + +## Try it + +1. **Add a mint step.** Drop a `delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)` before `require(authenticated)` (and the delegator plugin from module 13). Now the route *mints* instead of forwarding. Same allow result — a different, narrower token downstream. +2. **Expire the trust.** Point `audiences:` at something the token doesn't carry (e.g. `[workday-api]`) and re-run alice. Expect: inbound validation fails — passthrough still requires a *valid* token, it just doesn't exchange it. + +## Checkpoint + +{{< details "How is this different from module 2?" >}} +Module 2 resolved identity to make an *authorization* decision. Passthrough is about the *outbound* token: having validated the caller, the route forwards that same token rather than minting a new one. The novelty is what leaves the boundary, not how the caller is identified. +{{< /details >}} + +{{< details "When is passthrough the wrong choice?" >}} +When the caller's token is broader than the downstream call needs. Forwarding it hands the backend more authority than necessary, and a leak isn't contained. Mint a scoped token instead (modules 6, 13). +{{< /details >}} + +## Go deeper + +- [Recipe 4 — Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4--forward-a-token-the-caller-already-has-passthrough" >}}) for the reference version and where it fits among the delegation subjects. + +## Next + +[Module 15: Dual-principal]({{< relref "15-dual-principal" >}}): mint on behalf of a user while naming the agent that carried out the call. diff --git a/docs/content/docs/tutorial/15-dual-principal.md b/docs/content/docs/tutorial/15-dual-principal.md new file mode 100644 index 00000000..29d872d2 --- /dev/null +++ b/docs/content/docs/tutorial/15-dual-principal.md @@ -0,0 +1,105 @@ +--- +title: "Dual-principal (subject + actor)" +weight: 16 +--- + +# Module 15: Dual-principal delegation (who authorized vs. who acted) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. +> +> **Cookbook recipe:** [Recipe 6 — User acting through an agent, with the agent named]({{< relref "/docs/identity-delegation#recipe-6--user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}). + +**Goal:** mint a token that speaks *for* the user and also *names the agent* that carried out the call — two principals on one exchange. + +## The problem + +Every delegation so far spoke for a single principal. But the common agentic shape has two parties: a **human decides**, and an **agent acts**. You want the audit trail — and the minted token — to record both: the user as the authority (`sub`), and the agent as the acting party (`act`). RFC 8693 calls this **delegation**; recording only the subject is **impersonation**. + +Two ideas, kept separate: + +- **`subject:`** — who the token speaks **for** (whose authority). Least-privilege scoping follows the subject. +- **`actor:`** — who is **doing** it (attribution). The `act` claim records the agent; it grants nothing. + +## Build it + +Both credentials arrive on **every** call, on different headers, and a resolver picks each up. From [`policies/m15.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m15.yaml): + +```yaml +plugins: + - name: jwt-user # the human, on X-User-Token -> subject slot + kind: identity/jwt + config: { role: user, header: X-User-Token, audiences: [cpex-tutorial], ... } + - name: jwt-agent # the agent, on Authorization -> client slot + kind: identity/jwt + config: { role: client, header: Authorization, audiences: [cpex-gateway], ... } + +routes: + - tool: get_compensation + authentication: [jwt-user, jwt-agent] # both must resolve + authorization: + pre_invocation: + - "require(role.hr)" # authorize on the SUBJECT (the user) + - "delegate(workday-oauth, target: workday-api, audience: workday-api, + subject: user, actor: client)" + - "require(delegation.granted)" +``` + +`subject: user` makes the user's token the RFC 8693 `subject_token`; `actor: client` *additionally* attaches the agent's token as the `actor_token`. **One exchange, two principals in the request** — not a second leg. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m15_dual_principal +``` + +``` +▸ alice (X-User-Token) + agent (Authorization) → get_compensation (subject: user, actor: client) + ✓ ALLOWED { ... } + +▸ alice only, no agent token → get_compensation (actor credential missing) + ✗ DENIED [auth.malformed_header] header 'Authorization' missing ... (resolver 'jwt-agent') + +▸ sync_index (subject: this_workload + actor: client) → rejected as an invalid combo + ✗ DENIED [...] `actor:` is not supported with `subject: caller_workload` or `subject: this_workload` ... +``` + +The first call carried both principals and went through. The second was missing the agent credential — a dual-principal route needs *both*. The third is the guardrail below. + +## Interop: `act` is the token service's call + +Here is the honest part. **CPEX always puts the actor on the wire** (`actor_token` + `actor_token_type`), exactly as RFC 8693 delegation prescribes. Whether `act` lands in the minted token is up to the token service: + +- A **delegation-capable** service records the actor in a nested `act` claim. +- An **impersonation-only** service returns a subject-only token and ignores the actor. + +**Keycloak's Standard Token Exchange is impersonation-only** — it silently ignores `actor_token` and emits no `act` (verified here on 26.x). So the first scenario *succeeds*, but the minted token names only alice. To see `act` end to end you need a delegation-capable token service; against Keycloak, capture the acting agent at the CPEX boundary instead (audit, or a downstream header) — CPEX resolved both principals either way. + +This is why the module asserts the exchange *succeeds* rather than inspecting the token for `act`: the CPEX half is correct regardless, and the claim's presence isn't CPEX's to guarantee. + +## The guardrail + +`actor:` only pairs with an **on-behalf-of** subject (`user` or `client`). With `subject: this_workload` (a `client_credentials` grant that carries no `actor_token`) or `subject: caller_workload` (already the acting workload), an actor is meaningless — so CPEX **denies the step** rather than silently dropping it. The `sync_index` route shows the exact error. + +## Try it + +1. **Actor as `caller_workload`.** That variant names the agent by its SPIFFE SVID instead of an OAuth client — same `subject: user`, but `actor: caller_workload`. It needs a SPIFFE issuer (SPIRE); see [module 16]({{< relref "16-workload" >}}). +2. **Authorize on the actor by mistake.** Change `require(role.hr)` to gate on a client attribute. Authorization follows the *subject*; the actor is attribution, not authority — a good way to feel the difference. + +## Checkpoint + +{{< details "Why does the ALLOW scenario not prove `act` is in the token?" >}} +Because with Keycloak it isn't — its exchange is impersonation-only and drops the actor. CPEX still sends it correctly; emitting `act` is the token service's job. The test asserts what CPEX controls (the exchange succeeds, both principals resolved), not what the IdP controls. +{{< /details >}} + +{{< details "subject vs. actor — which one is scoped?" >}} +The subject. Least-privilege scoping follows whose authority the token carries. `actor:` only adds attribution — it records who acted, and grants nothing. +{{< /details >}} + +## Go deeper + +- [Recipe 6 — dual-principal]({{< relref "/docs/identity-delegation#recipe-6--user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}) for the reference version and the full interop note. +- [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the `subject:` / `actor:` contract and valid combinations. + +## Next + +[Module 16: Workload identity (SVID)]({{< relref "16-workload" >}}): the agent proves itself with a SPIFFE SVID, and CPEX exchanges it in two legs. diff --git a/docs/content/docs/tutorial/16-workload.md b/docs/content/docs/tutorial/16-workload.md new file mode 100644 index 00000000..516e99ff --- /dev/null +++ b/docs/content/docs/tutorial/16-workload.md @@ -0,0 +1,118 @@ +--- +title: "Workload identity (SVID)" +weight: 17 +--- + +# Module 16: Workload identity (the agent proves itself with a SPIFFE SVID) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP **and** the SPIRE overlay. +> +> **Cookbook recipe:** [Recipe 2 — Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2--agent-acting-as-itself-by-its-spiffe-svid" >}}). + +**Goal:** let a workload authenticate with a **SPIFFE SVID** — no user, no client secret — and have CPEX broker a scoped downstream token from it. + +## The problem + +Every caller so far held an OAuth credential: a user's JWT (module 6), a client's token (module 13), or nothing (passthrough). But a workload's native identity is a **SPIFFE SVID** — an ES256 JWT signed by **SPIRE**, not your IdP. It proves *what the workload is* (`sub` = `spiffe://…/agent/hr-copilot`), attested by the platform, with no secret to leak. + +An SVID is an identity credential, **not an OAuth token**: it can't be forwarded downstream or used as an exchange subject as-is. So `subject: caller_workload` runs **two legs**: + +1. **Authenticate** — present the SVID as an RFC 7523 `client_assertion` (type `…:jwt-spiffe`) so the IdP issues an ordinary token for the agent. +2. **Scope** — exchange that token (RFC 8693) for the downstream-scoped token. + +The agent holds no standing entitlement to the target — CPEX brokers the scope-up with its **own** gateway credential (leg 2). A compromised agent can prove *who it is* but cannot mint the downstream token itself. That is what makes CPEX the trust boundary. + +## Bring up the infrastructure + +This module needs a SPIFFE authority (SPIRE) and a Keycloak that speaks SPIFFE. Both come from the **opt-in overlay** — modules 0–15 don't use it. + +```bash +# SPIRE server + OIDC provider, and Keycloak bumped to 26.6.1 with spiffe:v1 +docker compose -f examples/tutorial/idp/docker-compose.yml \ + -f examples/tutorial/idp/docker-compose.spire.yml up -d + +# Trust SPIRE and bind the agent's SPIFFE ID to a federated-jwt client +./examples/tutorial/idp/spire/setup-spiffe.sh +``` + +The setup script does two things via the admin API (kept out of the realm export so the base Keycloak never sees SPIFFE config): registers a **SPIFFE identity provider** that validates SVIDs against SPIRE's JWKS, and creates a **`federated-jwt` client** (`hr-copilot-agent`) bound to `spiffe://cpex.tutorial/agent/hr-copilot`, with an audience mapper so the gateway can exchange its token. + +## Build it + +One resolver for the SVID, and a `subject: caller_workload` delegation. From [`policies/m16.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m16.yaml): + +```yaml +plugins: + - name: jwt-workload + kind: identity/jwt + config: + role: caller_workload # -> the caller_workload slot + header: X-Workload-Token + trusted_issuers: + - issuer: http://spire-oidc:8443 # SPIRE, not the IdP + audiences: [http://localhost:8081/realms/cpex-tutorial] # SVID aud = the IdP + algorithms: [ES256] # SVIDs are EC-signed + decoding_key: { kind: jwks_url, url: "http://localhost:8443/keys" } + +routes: + - tool: search_repos + authentication: [jwt-workload] # authenticate by the SVID alone + authorization: + pre_invocation: + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: caller_workload)" + - "require(delegation.granted)" +``` + +The delegator is the same `cpex-gateway` OAuth delegator as every other module — `subject: caller_workload` is what tells it to run the two legs. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m16_workload +``` + +``` +▸ agent (SVID on X-Workload-Token) → search_repos (subject: caller_workload, two-leg) + ✓ ALLOWED { ... "repositories":[ ... ] } + +▸ anonymous → search_repos (no SVID, the workload route can't authenticate) + ✗ DENIED [delegation.bad_request] ... empty bearer_token ... +``` + +The example mints the SVID off SPIRE (`spire-server jwt mint`), presents it on `X-Workload-Token`, and CPEX does the rest: validate → `caller_workload` → leg 1 → leg 2 → a `github-api`-scoped token. The agent never saw that token. + +## The SVID vs. a token minted from one + +This is the distinction to hold onto (it's the line between this module and module 13): + +| The agent presents | Slot → subject | CPEX does | Legs | +|---|---|---|---| +| its **SVID** (ES256, SPIRE's JWKS) | `caller_workload` → `subject: caller_workload` | authenticate **+** scope | 2 (this module) | +| a **token minted from its SVID** (RS256, IdP's JWKS) | `client` → `subject: client` | scope only | 1 ([module 13]({{< relref "13-client" >}})) | + +Match the subject to what actually arrived. Using `subject: caller_workload` on an already-minted token would misroute it down the two-leg `client_assertion` path. + +## Try it + +1. **Name the agent as the actor.** Combine with [module 15]({{< relref "15-dual-principal" >}}): add a user on `X-User-Token`, keep the SVID, and use `subject: user, actor: caller_workload` — the human authorizes, the agent (by SVID) is the actor. +2. **Tamper with the SVID.** Change one character of the token before presenting it. Expect: the resolver rejects it — the ES256 signature no longer verifies against SPIRE's JWKS. +3. **Wrong audience.** Mint the SVID with `-audience something-else`. Expect: leg 1 fails — the SPIFFE client-auth draft requires the SVID's audience to be the IdP it authenticates to. + +## Checkpoint + +{{< details "Why two legs, when module 13 needed only one?" >}} +Because an SVID is not an IdP token. Module 13's caller already held an IdP-issued token (it did the authenticate leg itself), so CPEX only scoped it. Here the agent holds an SVID, so CPEX must first turn it into an IdP token (leg 1) and then scope it (leg 2). +{{< /details >}} + +{{< details "Where does the downstream authority come from?" >}} +Leg 2 — CPEX's own gateway credential — not the agent. The SVID proves identity; it grants nothing downstream. That separation is why a compromised agent can't mint the target token itself. +{{< /details >}} + +## Go deeper + +- [Recipe 2 — Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2--agent-acting-as-itself-by-its-spiffe-svid" >}}) for the reference version and the IdP-support notes (tested on Keycloak 26.6, `spiffe:v1`). +- [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` contract. + +## Next + +[Module 17: Multi-issuer identity]({{< relref "17-federation" >}}): accept callers from more than one IdP with a single resolver. diff --git a/docs/content/docs/tutorial/17-federation.md b/docs/content/docs/tutorial/17-federation.md new file mode 100644 index 00000000..abfcade5 --- /dev/null +++ b/docs/content/docs/tutorial/17-federation.md @@ -0,0 +1,90 @@ +--- +title: "Multi-issuer (trust federation)" +weight: 18 +--- + +# Module 17: Multi-issuer identity (trust more than one IdP) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** accept callers from **more than one identity provider** with a single resolver, each token validated against its own issuer's keys. + +## The problem + +Every module so far trusted exactly one issuer. Real enforcement points rarely have that luxury: your own workforce IdP *and* a partner org's, a legacy realm *and* its replacement during a migration, one IdP per business unit. You want to accept tokens from all of them — but only those — and validate each on the correct keys. + +A JWT names its issuer in the `iss` claim. CPEX matches that to a **trusted issuer** and validates the token against *that* issuer's JWKS. List several, and one resolver federates them; a token whose `iss` is in none is rejected with `auth.untrusted_issuer`. + +## Build it + +One resolver, two `trusted_issuers`. From [`policies/m17.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m17.yaml): + +```yaml +plugins: + - name: keycloak + kind: identity/jwt + config: + role: user + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial # home realm + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: { kind: jwks_url, url: "…/cpex-tutorial/…/certs" } + - issuer: http://localhost:8081/realms/cpex-partner # partner realm + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: { kind: jwks_url, url: "…/cpex-partner/…/certs" } # ITS OWN keys +``` + +Each entry is a full trust anchor: its own issuer string, its own JWKS, its own accepted audiences. Here both issuers are realms in the same Keycloak, but they could be entirely separate products — the resolver doesn't care, it just matches `iss`. + +## Run it + +The tutorial IdP imports a second realm, `cpex-partner`, with one user (`pat`). + +```bash +cargo run -p cpex-tutorial --example m17_federation +``` + +``` +▸ alice (home realm cpex-tutorial) → get_compensation (issuer #1, role.hr) + ✓ ALLOWED { ... } + +▸ pat (partner realm cpex-partner) → get_compensation (issuer #2, validated on ITS keys) + ✓ ALLOWED { ... } + +▸ outsider (master realm — an untrusted issuer) → get_compensation (rejected) + ✗ DENIED [auth.untrusted_issuer] issuer 'http://localhost:8081/realms/master' is not in the trusted-issuer list +``` + +Both `alice` and `pat` reach the same route under the same rule (`require(role.hr)`) — the policy never mentions issuers. The third token is a genuine, validly-signed JWT, but from Keycloak's `master` realm, which the resolver doesn't trust — so it's rejected before any authorization runs. + +## Claims still have to line up + +Federation validates *signatures*; it does not normalize *claims*. Two IdPs may express roles differently, and the resolver reads a flat `roles` array (module 2). The partner realm here is configured to emit the same `roles` / `permissions` shape as the home realm, which is why `pat` satisfies `require(role.hr)`. When you federate a *real* partner IdP, mapping its claims into the shape your policy expects is the actual work — the trust list is the easy half. + +## Try it + +1. **Drop the partner issuer.** Delete the second `trusted_issuers` entry and re-run. Expect: `pat` now fails with `auth.untrusted_issuer` — same token, no longer trusted. +2. **Break the partner keys.** Point the partner entry's `decoding_key.url` at the *home* realm's certs. Expect: `pat` fails signature validation — each issuer must be verified on its own keys. +3. **Diverge the claims.** Give `pat` a role the home realm doesn't use and gate the route on it. That's the per-issuer claim-mapping problem in miniature. + +## Checkpoint + +{{< details "How does CPEX pick which keys to validate a token with?" >}} +By the token's `iss` claim. It finds the matching entry in `trusted_issuers` and validates against that entry's JWKS and accepted audiences. No match → `auth.untrusted_issuer`, before any policy runs. +{{< /details >}} + +{{< details "Is a trusted issuer the same as trusting everything it says?" >}} +No. Trust means "I accept tokens this issuer signed." What those tokens are *allowed* to do is still your authorization policy, and whether their claims mean what yours mean is still claim mapping. Federation is authentication, not authorization. +{{< /details >}} + +## Go deeper + +- [Identity → Multiple sources]({{< relref "/docs/apl/identity#multiple-sources" >}}) for the full multi-issuer / multi-resolver model. +- [Configuration]({{< relref "/docs/configuration" >}}) for the `trusted_issuers` schema. + +## Next + +[Module 18: Static attributes]({{< relref "18-attributes" >}}): feed policy operator-maintained facts from a data file, read as `data.*`. diff --git a/docs/content/docs/tutorial/18-attributes.md b/docs/content/docs/tutorial/18-attributes.md new file mode 100644 index 00000000..26d01d77 --- /dev/null +++ b/docs/content/docs/tutorial/18-attributes.md @@ -0,0 +1,107 @@ +--- +title: "Static attributes (data.*)" +weight: 19 +--- + +# Module 18: Static attributes (operator-maintained facts) + +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. + +**Goal:** feed policy facts that no token carries — operator decisions maintained in a data file — and read them per request as `data.*`. + +## The problem + +Most attributes a predicate reads come from the request: the subject and its roles (module 2), session labels (module 7), headers. But some facts are carried by nothing. Which region a deployment's data must stay in. Which tools are switched on this week. The org's default tier. These are **operator decisions**, known at configuration time and belonging in neither the token nor the application code. + +CPEX provisions them from a plain data file into the `data.*` namespace. Identity turns a token into `subject.*`; static provisioning turns a config file into `data.*` — and predicates read both the same way. + +## Build it + +A data file of facts (note the top-level `data:` wrapper). From [`policies/attributes/controls.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/attributes/controls.yaml): + +```yaml +data: + org: + data_region: eu + controls: + tools: + get_compensation: { enabled: false } # frozen by operations + search_repos: { enabled: true } +``` + +List it under `global.apl.attribute_files`, and read it in a rule. From [`policies/m18.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m18.yaml): + +```yaml +global: + apl: + attribute_files: + - examples/tutorial/policies/attributes/controls.yaml # relative to repo root + +routes: + - tool: get_compensation + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" + - when: "data.controls.tools.get_compensation.enabled == false" + do: + - "deny('get_compensation is disabled by operations', 'ops.tool_disabled')" +``` + +The whole file flattens into the bag: `data.org.data_region`, `data.controls.tools.get_compensation.enabled`, and so on. Multiple files deep-merge in order, and the merge is **fail-fast** — two files setting the same leaf differently is a load error, and a file missing its `data:` wrapper is rejected. A config mistake stops startup rather than quietly mis-routing. + +## Run it + +```bash +cargo run -p cpex-tutorial --example m18_attributes +``` + +``` +▸ alice → get_compensation (data.controls says this tool is disabled) + ✗ DENIED [ops.tool_disabled] get_compensation is disabled by operations + +▸ alice → search_repos (data.controls says this tool is enabled) + ✓ ALLOWED { ... } +``` + +The same caller reached one tool and was refused another. Nothing about alice changed between the two calls — the outcome came from a fact in a file, read as `data.*`. + +## Reading the tree per caller + +The powerful form indexes the tree by a *request* value, so one rule serves every caller: + +```yaml +- when: "data.tenants[subject.tenant].data_region == 'eu'" + do: [ "taint(eu_resident, session)" ] +``` + +`[subject.tenant]` is substituted at evaluation time — the predicate reads `data.tenants..data_region`. If the indexed value is missing, the whole path resolves to absent and the predicate is simply false (a `require` on it fails closed), so an unknown caller is never silently unconstrained. The set-valued fields of the [`restrict`]({{< relref "/docs/apl/restrict" >}}) effect can take a `data.*` reference the same way — one routing rule, per-caller allow-lists from the tree. + +## Data, not a rules engine + +The tree holds **literal values only** — no conditionals, no computed fields, no cross-references. Any "if X then Y" is policy's job; put it in a route. A plain data document has no syntax to express logic, so the static layer can't quietly grow into a second, shadow policy engine. It provisions the facts; APL decides with them. + +## Try it + +1. **Flip a switch.** Set `search_repos.enabled: false` in `controls.yaml` and re-run. Expect: now *both* tools are refused — you changed behavior by editing a data file, touching no policy and no code. +2. **Break the merge.** Add a second file to `attribute_files` that sets `data.org.data_region` to a *different* value. Expect: a load-time error — the merge is fail-fast, not last-wins. +3. **Forget the wrapper.** Remove the top-level `data:` key from the file. Expect: rejected at load — the file must declare `data:`. + +## Checkpoint + +{{< details "How is data.* different from subject.*?" >}} +`subject.*` is dynamic — resolved from the token this request carried. `data.*` is static — provisioned from a file at startup and shared across requests. One is what the caller *is*; the other is what the operator has *decided*. Predicates read both identically and combine them freely. +{{< /details >}} + +{{< details "Why not just put the flag in the route?" >}} +You could — but then flipping a tool off is a policy edit and review. Facts an operator changes often (kill switches, region maps, per-tenant tiers) live better as data an operator maintains, keyed by the request, so one rule serves everyone and the change is a data edit, not a policy one. +{{< /details >}} + +## Go deeper + +- [Static Attributes]({{< relref "/docs/apl/attributes" >}}) for the full `data.*` model, merge rules, and the `AttributeSource` trait (etcd / DB / ConfigMap sources). +- [Backend Restriction]({{< relref "/docs/apl/restrict" >}}) — where `data.*` references feed per-caller routing constraints. + +## Next + +[Capstone]({{< relref "capstone" >}}): reconstruct the full scenario end to end. diff --git a/docs/content/docs/tutorial/_index.md b/docs/content/docs/tutorial/_index.md index 71c57fc1..755c85ab 100644 --- a/docs/content/docs/tutorial/_index.md +++ b/docs/content/docs/tutorial/_index.md @@ -39,6 +39,14 @@ Budget about 3 to 4 hours total, 15 to 25 minutes per module. | 8 | [Human in the loop]({{< relref "08-elicitation" >}}) | Suspend an operation for human approval, then resume | yes | | 9 | [Write your own plugin]({{< relref "09-custom-plugin" >}}) | Build a custom plugin with the SDK; reference it from policy | no | | 10 | [Testing your policy]({{< relref "10-testing" >}}) | Table-driven allow/deny tests that run in CI | no | +| 11 | [Organizing policy (Groups)]({{< relref "11-groups" >}}) | Factor shared identity/authz into reusable bundles routes join | yes | +| 12 | [Delegation subjects]({{< relref "12-subjects" >}}) | Mint a downstream token as the caller (`user`) or as the gateway itself (`this_workload`) | yes | +| 13 | [Delegation as a client]({{< relref "13-client" >}}) | Scope an agent's own client token with `subject: client` (cookbook Recipe 5) | yes | +| 14 | [Passthrough (forward, don't mint)]({{< relref "14-passthrough" >}}) | When to forward the caller's token unchanged instead of exchanging it (cookbook Recipe 4) | yes | +| 15 | [Dual-principal (subject + actor)]({{< relref "15-dual-principal" >}}) | Mint on behalf of a user while naming the acting agent — `subject: user, actor: client` (cookbook Recipe 6) | yes | +| 16 | [Workload identity (SVID)]({{< relref "16-workload" >}}) | An agent authenticates by SPIFFE SVID; CPEX brokers a scoped token in two legs — `subject: caller_workload` (cookbook Recipe 2) | yes + SPIRE | +| 17 | [Multi-issuer (trust federation)]({{< relref "17-federation" >}}) | Accept callers from more than one IdP with one resolver, each validated on its own keys | yes | +| 18 | [Static attributes (data.*)]({{< relref "18-attributes" >}}) | Feed policy operator-maintained facts from a data file, read per request as `data.*` | yes | | C | [Capstone: the three-backend agent]({{< relref "capstone" >}}) | Assemble every control into the full Overview scenario | yes | Start at [module 0]({{< relref "00-setup" >}}). diff --git a/docs/content/docs/tutorial/capstone.md b/docs/content/docs/tutorial/capstone.md index 8b78d72e..e5a583b3 100644 --- a/docs/content/docs/tutorial/capstone.md +++ b/docs/content/docs/tutorial/capstone.md @@ -1,6 +1,6 @@ --- title: "Capstone" -weight: 12 +weight: 20 --- # Capstone: the three-backend agent diff --git a/examples/tutorial/examples/m11_groups.rs b/examples/tutorial/examples/m11_groups.rs new file mode 100644 index 00000000..851a3149 --- /dev/null +++ b/examples/tutorial/examples/m11_groups.rs @@ -0,0 +1,126 @@ +// Location: ./examples/tutorial/examples/m11_groups.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 11, Organizing policy (Groups). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m11_groups +// cargo run -p cpex-tutorial --example m11_groups -- --check +// +// Three routes share one `identified` group that resolves the caller's token +// with `keycloak`. The resolver is written once, not per route. Each route +// then adds only its own authorization, so policy still decides every outcome +// per caller: alice (hr) clears the hr route, evan (engineer) clears the +// engineering route, and either may send email. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m11.yaml"); + +async fn token(user: &str) -> Caller { + match idp::mint_token(user, user).await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 11: Organizing policy (Groups)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m11.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let alice = token("alice").await; + let evan = token("evan").await; + let mut all_passed = true; + + // The hr route: joined `identified` (keycloak resolves the token), then + // require(role.hr). + ui::scenario( + "alice (hr) → get_compensation (group resolves her token, require(role.hr) passes)", + ); + let o = mediate( + &mgr, + &alice, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("evan (engineer) → get_compensation (resolved by the same group, denied at require(role.hr))"); + let o = mediate( + &mgr, + &evan, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + // The engineering route: same group, a different requirement. + ui::scenario("evan (engineer) → search_repos (same group, require(role.engineer) passes)"); + let o = mediate( + &mgr, + &evan, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("alice (hr) → search_repos (denied at require(role.engineer))"); + let o = mediate( + &mgr, + &alice, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + // The open route: the group still resolves the token; no role required. + ui::scenario( + "alice (hr) → send_email (group resolves her token; require(authenticated) passes)", + ); + let o = mediate( + &mgr, + &alice, + "send_email", + json!({ "to": "coworker@corp.example", "subject": "lunch" }), + backends::send_email, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + println!("One group carried the shared resolver for all three routes; each route added only its own authorization."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m12_subjects.rs b/examples/tutorial/examples/m12_subjects.rs new file mode 100644 index 00000000..2f5812f6 --- /dev/null +++ b/examples/tutorial/examples/m12_subjects.rs @@ -0,0 +1,112 @@ +// Location: ./examples/tutorial/examples/m12_subjects.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 12, Delegation subjects (who the downstream call speaks for). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m12_subjects +// cargo run -p cpex-tutorial --example m12_subjects -- --check +// +// Two routes mint a downstream token differently: +// * get_compensation uses `subject: user` — it exchanges the CALLER's token, +// so an anonymous request has nothing to exchange and delegation fails. +// * search_repos uses `subject: this_workload` — it mints a token as the +// GATEWAY (client_credentials), so it works with no caller at all. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m12.yaml"); + +async fn token(user: &str) -> Caller { + match idp::mint_token(user, user).await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 12: Delegation subjects (who the call speaks for)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m12.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let alice = token("alice").await; + let anon = Caller::anonymous(); + let mut all_passed = true; + + // subject: user — on behalf of the caller. Needs a caller token to exchange. + ui::scenario("alice → get_compensation (subject: user — exchanges alice's token)"); + let o = mediate( + &mgr, + &alice, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario( + "anonymous → get_compensation (subject: user — no token to exchange, delegation fails)", + ); + let o = mediate( + &mgr, + &anon, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + // subject: this_workload — as the gateway itself. No caller token needed. + ui::scenario( + "anonymous → search_repos (subject: this_workload — gateway mints via client_credentials)", + ); + let o = mediate( + &mgr, + &anon, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("alice → search_repos (subject: this_workload — same result; the caller's identity is not used)"); + let o = mediate( + &mgr, + &alice, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + println!("`subject:` chooses whose authority the downstream token carries: the caller (user) or the gateway itself (this_workload)."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m13_client.rs b/examples/tutorial/examples/m13_client.rs new file mode 100644 index 00000000..44655193 --- /dev/null +++ b/examples/tutorial/examples/m13_client.rs @@ -0,0 +1,86 @@ +// Location: ./examples/tutorial/examples/m13_client.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 13, Delegation subject: client (the agent scopes its OWN token). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m13_client +// cargo run -p cpex-tutorial --example m13_client -- --check +// +// The caller is not a person: the agent authenticates to the IdP as its own +// OAuth client (client_credentials) and arrives holding a token that speaks +// for itself. `subject: client` scopes THAT token down to the workday-api +// audience — a one-leg RFC 8693 exchange. Like subject: user, it needs an +// inbound credential, so an anonymous request has nothing to exchange. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m13.yaml"); + +/// Mint a token for the agent authenticating as the `cpex-agent` OAuth client. +async fn agent_caller() -> Caller { + match idp::mint_client_token("cpex-agent", "agent-dev-secret").await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 13: Delegation subject: client (the agent scopes its own token)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m13.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let agent = agent_caller().await; + let anon = Caller::anonymous(); + let mut all_passed = true; + + // subject: client — scope the agent's OWN token. Needs an inbound client token. + ui::scenario( + "agent (cpex-agent client) → search_repos (subject: client — scopes the agent's own token)", + ); + let o = mediate( + &mgr, + &agent, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("anonymous → search_repos (subject: client — no client token to exchange, delegation fails)"); + let o = mediate( + &mgr, + &anon, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!("`subject: client` scopes the CALLER's own client token — an agent acting as itself, not on behalf of a user."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m14_passthrough.rs b/examples/tutorial/examples/m14_passthrough.rs new file mode 100644 index 00000000..1b57431f --- /dev/null +++ b/examples/tutorial/examples/m14_passthrough.rs @@ -0,0 +1,86 @@ +// Location: ./examples/tutorial/examples/m14_passthrough.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 14, Passthrough (forward the caller's token, mint nothing). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m14_passthrough +// cargo run -p cpex-tutorial --example m14_passthrough -- --check +// +// The route has NO delegate step. CPEX validates the caller's token inbound +// and forwards it as-is — the zero-leg case. Contrast modules 6/13, which +// mint a fresh scoped token. Here the caller's own token flows downstream. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m14.yaml"); + +async fn token(user: &str) -> Caller { + match idp::mint_token(user, user).await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 14: Passthrough (forward the caller's token, mint nothing)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m14.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let alice = token("alice").await; + let anon = Caller::anonymous(); + let mut all_passed = true; + + // A validated caller: no delegate step runs, so the token alice presented + // is what would flow downstream. CPEX validated and forwarded it. + ui::scenario("alice → search_repos (validated and forwarded — no token minted)"); + let o = mediate( + &mgr, + &alice, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + // Passthrough still needs a valid inbound token: require(authenticated) + // gates it. An anonymous caller has nothing to forward. + ui::scenario("anonymous → search_repos (no token to forward, require(authenticated) denies)"); + let o = mediate( + &mgr, + &anon, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!( + "No delegate step: CPEX validated the caller's token and forwarded it unchanged — choose this only when that token is already scoped for the downstream." + ); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m15_dual_principal.rs b/examples/tutorial/examples/m15_dual_principal.rs new file mode 100644 index 00000000..8eb5eece --- /dev/null +++ b/examples/tutorial/examples/m15_dual_principal.rs @@ -0,0 +1,114 @@ +// Location: ./examples/tutorial/examples/m15_dual_principal.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 15, Dual-principal delegation (who authorized vs. who acted). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m15_dual_principal +// cargo run -p cpex-tutorial --example m15_dual_principal -- --check +// +// Two principals arrive on every call: the human on X-User-Token (the +// subject) and the agent on Authorization (the actor). `subject: user, +// actor: client` mints a token FOR the user and names the agent as the +// acting party. CPEX puts the actor on the wire per RFC 8693 delegation; +// Keycloak's exchange honors only the subject and drops `act` (see the doc). + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m15.yaml"); + +/// Fatal helper: mint a token or print the IdP hint and exit. +async fn user_token(user: &str) -> String { + idp::mint_token(user, user).await.unwrap_or_else(|e| { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }) +} + +async fn agent_token() -> String { + idp::mint_client_token("cpex-agent", "agent-dev-secret") + .await + .unwrap_or_else(|e| { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }) +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 15: Dual-principal delegation (who authorized vs. who acted)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m15.yaml should load"); + mgr.initialize().await.expect("initialize"); + + let alice = user_token("alice").await; + let agent = agent_token().await; + let mut all_passed = true; + + // Both principals present: alice authorizes (role.hr on the subject), + // the agent is named as the actor. The exchange goes through. + ui::scenario( + "alice (X-User-Token) + agent (Authorization) → get_compensation (subject: user, actor: client)", + ); + let both = Caller::with_token(agent.clone()).with_credential("X-User-Token", alice.clone()); + let o = mediate( + &mgr, + &both, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + // Only the user, no agent: a dual-principal route needs BOTH credentials, + // so the missing actor token on Authorization denies at identity. + ui::scenario("alice only, no agent token → get_compensation (actor credential missing)"); + let user_only = Caller::anonymous().with_credential("X-User-Token", alice.clone()); + let o = mediate( + &mgr, + &user_only, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + // The guardrail: `actor:` with `subject: this_workload` is invalid — that + // grant carries no actor_token. CPEX denies the step rather than silently + // dropping the actor. No caller is needed; the check precedes the exchange. + ui::scenario( + "sync_index (subject: this_workload + actor: client) → rejected as an invalid combo", + ); + let o = mediate( + &mgr, + &Caller::anonymous(), + "sync_index", + json!({}), + |_: &serde_json::Value| json!({ "synced": true }), + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!("`subject:` is who the token speaks FOR; `actor:` is who is DOING it. Both principals ride one exchange."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m16_workload.rs b/examples/tutorial/examples/m16_workload.rs new file mode 100644 index 00000000..e22b6c75 --- /dev/null +++ b/examples/tutorial/examples/m16_workload.rs @@ -0,0 +1,89 @@ +// Location: ./examples/tutorial/examples/m16_workload.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 16, Workload identity (the agent proves itself with an SVID). +// +// Prerequisites: the tutorial IdP AND the SPIRE overlay must be running, and +// Keycloak must be wired to trust SPIRE: +// docker compose -f examples/tutorial/idp/docker-compose.yml \ +// -f examples/tutorial/idp/docker-compose.spire.yml up -d +// ./examples/tutorial/idp/spire/setup-spiffe.sh +// +// cargo run -p cpex-tutorial --example m16_workload +// cargo run -p cpex-tutorial --example m16_workload -- --check +// +// The caller is a workload with a SPIFFE identity. It presents its ES256 +// JWT-SVID (minted by SPIRE) on X-Workload-Token — no user, no client secret. +// CPEX validates it against SPIRE's JWKS -> caller_workload, then runs the +// two-leg delegation: leg 1 turns the SVID into an IdP token (jwt-spiffe +// client_assertion), leg 2 exchanges that for a github-api-scoped token. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m16.yaml"); +const SPIFFE_ID: &str = "spiffe://cpex.tutorial/agent/hr-copilot"; + +#[tokio::main] +async fn main() { + ui::module_banner("Module 16: Workload identity (the agent proves itself with an SVID)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m16.yaml should load"); + mgr.initialize().await.expect("initialize"); + + // Mint the agent's SVID off SPIRE. This is the agent's identity credential + // — an ES256 JWT signed by SPIRE, audience = the tutorial realm issuer. + let svid = idp::mint_svid(SPIFFE_ID).unwrap_or_else(|e| { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }); + let mut all_passed = true; + + // The agent presents ONLY its SVID (no user, no client token). CPEX + // validates it -> caller_workload, then brokers a scoped token in two legs. + ui::scenario( + "agent (SVID on X-Workload-Token) → search_repos (subject: caller_workload, two-leg)", + ); + let agent = Caller::anonymous().with_credential("X-Workload-Token", svid); + let o = mediate( + &mgr, + &agent, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + // No SVID: the route authenticates by the workload token alone, so an + // anonymous request has no identity to broker from. + ui::scenario("anonymous → search_repos (no SVID, the workload route can't authenticate)"); + let o = mediate( + &mgr, + &Caller::anonymous(), + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!( + "The agent proved itself with a SPIFFE SVID and never held the downstream token — CPEX brokered it in two legs." + ); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m17_federation.rs b/examples/tutorial/examples/m17_federation.rs new file mode 100644 index 00000000..158c6ee8 --- /dev/null +++ b/examples/tutorial/examples/m17_federation.rs @@ -0,0 +1,102 @@ +// Location: ./examples/tutorial/examples/m17_federation.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 17, Multi-issuer identity (trust more than one IdP). +// +// Prerequisite: the tutorial IdP must be running (it imports both the home +// realm `cpex-tutorial` and the partner realm `cpex-partner`). +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// cargo run -p cpex-tutorial --example m17_federation +// cargo run -p cpex-tutorial --example m17_federation -- --check +// +// One resolver trusts two issuers. A token is validated against the JWKS of +// whichever trusted issuer its `iss` claim names; a token from an issuer in +// neither list is rejected with auth.untrusted_issuer. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m17.yaml"); + +/// Fatal helper: unwrap a minted token or print the IdP hint and exit. +fn or_exit(r: Result) -> String { + r.unwrap_or_else(|e| { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }) +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 17: Multi-issuer identity (trust more than one IdP)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m17.yaml should load"); + mgr.initialize().await.expect("initialize"); + + // Home realm: alice, minted from cpex-tutorial. + let alice = Caller::with_token(or_exit(idp::mint_token("alice", "alice").await)); + // Partner realm: pat, minted from a DIFFERENT issuer (cpex-partner). + let pat = Caller::with_token(or_exit( + idp::mint_token_in_realm("cpex-partner", "cpex-partner-app", "pat", "pat").await, + )); + // A valid JWT from an issuer the resolver does NOT trust (Keycloak's + // built-in master realm). + let outsider = Caller::with_token(or_exit( + idp::mint_token_in_realm("master", "admin-cli", "admin", "admin").await, + )); + let mut all_passed = true; + + ui::scenario("alice (home realm cpex-tutorial) → get_compensation (issuer #1, role.hr)"); + let o = mediate( + &mgr, + &alice, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario( + "pat (partner realm cpex-partner) → get_compensation (issuer #2, validated on ITS keys)", + ); + let o = mediate( + &mgr, + &pat, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + ui::scenario("outsider (master realm — an untrusted issuer) → get_compensation (rejected)"); + let o = mediate( + &mgr, + &outsider, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + println!("One resolver, two trusted issuers: CPEX validates each token on the keys of the issuer its `iss` names, and rejects the rest."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/examples/m18_attributes.rs b/examples/tutorial/examples/m18_attributes.rs new file mode 100644 index 00000000..b9482aa8 --- /dev/null +++ b/examples/tutorial/examples/m18_attributes.rs @@ -0,0 +1,83 @@ +// Location: ./examples/tutorial/examples/m18_attributes.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Tutorial module 18, Static attributes (operator facts in the data.* tree). +// +// Prerequisite: the tutorial IdP must be running. +// docker compose -f examples/tutorial/idp/docker-compose.yml up -d +// +// Run from the repo root (the policy's attribute_files path is relative to it): +// cargo run -p cpex-tutorial --example m18_attributes +// cargo run -p cpex-tutorial --example m18_attributes -- --check +// +// An operator's per-tool kill switch lives in a data file, loaded into the +// data.* tree. The same caller reaches one tool and is refused another — the +// difference is a fact in that file, not anything about the request. + +use std::sync::Arc; + +use cpex::PluginManager; +use cpex_tutorial::backends; +use cpex_tutorial::idp; +use cpex_tutorial::ui; +use cpex_tutorial::{mediate, Caller}; + +use serde_json::json; + +const POLICY: &str = include_str!("../policies/m18.yaml"); + +async fn token(user: &str) -> Caller { + match idp::mint_token(user, user).await { + Ok(t) => Caller::with_token(t), + Err(e) => { + eprintln!("\x1b[31m{e}\x1b[0m"); + std::process::exit(1); + }, + } +} + +#[tokio::main] +async fn main() { + ui::module_banner("Module 18: Static attributes (operator facts in the data.* tree)"); + + let mgr = Arc::new(PluginManager::default()); + cpex::install_builtins(&mgr); + mgr.load_config_yaml(POLICY) + .expect("policy m18.yaml should load (run from the repo root so attribute_files resolves)"); + mgr.initialize().await.expect("initialize"); + + let alice = token("alice").await; + let mut all_passed = true; + + // Same caller, two tools. get_compensation is switched OFF in the data + // file, so it's refused — nothing about alice changed. + ui::scenario("alice → get_compensation (data.controls says this tool is disabled)"); + let o = mediate( + &mgr, + &alice, + "get_compensation", + json!({ "employee_id": "e-1001" }), + backends::get_compensation, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, false); + + // search_repos is switched ON in the same file, so the same caller passes. + ui::scenario("alice → search_repos (data.controls says this tool is enabled)"); + let o = mediate( + &mgr, + &alice, + "search_repos", + json!({ "visibility": "internal" }), + backends::search_repos, + ) + .await; + ui::print_outcome(&o); + all_passed &= ui::expect(&o, true); + + println!("Same caller, opposite outcomes — decided by an operator fact in a data file, read as data.*, not by anything in the request."); + ui::finish_check(all_passed); +} diff --git a/examples/tutorial/idp/docker-compose.spire.yml b/examples/tutorial/idp/docker-compose.spire.yml new file mode 100644 index 00000000..095dff64 --- /dev/null +++ b/examples/tutorial/idp/docker-compose.spire.yml @@ -0,0 +1,75 @@ +# ============================================================================= +# CPEX Tutorial — SPIRE overlay for Module 16 (Workload identity / SVID). +# ============================================================================= +# OPT-IN. Modules 0–15 do not need this file — bring it up only for module 16. +# It is ADDITIVE over idp/docker-compose.yml: +# +# * bumps Keycloak to 26.6.1 and turns on `spiffe:v1` (the native SPIFFE +# identity provider — lets a client present its JWT-SVID as an RFC 7523 +# client_assertion of type ...:jwt-spiffe, which is how an SVID enters +# OAuth per draft-ietf-oauth-spiffe-client-auth). Also keeps +# `token-exchange-standard` on for leg 2 (the scope-down exchange). +# * adds spire-server (the SPIFFE authority) + spire-oidc (its JWKS/OIDC +# discovery provider on :8443) so Keycloak can validate the SVID. +# +# We mint SVIDs directly off the server (no agent, no attestation) — a genuine +# SPIRE-signed SVID against SPIRE's real JWKS, two containers. +# +# # Bring up the tutorial IdP + SPIRE: +# docker compose -f idp/docker-compose.yml -f idp/docker-compose.spire.yml up -d +# +# # Configure Keycloak to trust SPIRE and bind the agent's SPIFFE ID: +# ./idp/spire/setup-spiffe.sh +# +# # Mint the agent's JWT-SVID (audience = the tutorial realm's issuer): +# docker compose -f idp/docker-compose.yml -f idp/docker-compose.spire.yml \ +# exec spire-server /opt/spire/bin/spire-server jwt mint \ +# -spiffeID spiffe://cpex.tutorial/agent/hr-copilot \ +# -audience http://localhost:8081/realms/cpex-tutorial +# +# NEVER use any part of this stack in production. +# ============================================================================= + +services: + # Override the base Keycloak: newer image + SPIFFE feature. Every other + # setting (ports, realm import, admin creds, healthcheck) is inherited. + keycloak: + image: quay.io/keycloak/keycloak:26.6.1 + environment: + # token-exchange-standard -> RFC 8693 exchange (leg 2), as before. + # spiffe:v1 -> native SPIFFE identity provider + the + # federated-jwt client authenticator (leg 1). + KC_FEATURES: token-exchange-standard,spiffe:v1 + + spire-server: + image: ghcr.io/spiffe/spire-server:1.9.6 + container_name: cpex-tutorial-spire-server + command: ["-config", "/opt/spire/conf/server/server.conf"] + volumes: + - ./spire/server/server.conf:/opt/spire/conf/server/server.conf:ro + - spire-server-data:/opt/spire/data/server + # Shared admin socket dir — spire-oidc reads the bundle through it. + - spire-server-socket:/tmp/spire-server/private + healthcheck: + test: ["CMD", "/opt/spire/bin/spire-server", "healthcheck"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + + spire-oidc: + image: ghcr.io/spiffe/oidc-discovery-provider:1.9.6 + container_name: cpex-tutorial-spire-oidc + command: ["-config", "/opt/spire/conf/oidc/oidc-discovery-provider.conf"] + depends_on: + spire-server: + condition: service_healthy + volumes: + - ./spire/oidc/oidc-discovery-provider.conf:/opt/spire/conf/oidc/oidc-discovery-provider.conf:ro + - spire-server-socket:/tmp/spire-server/private:ro + ports: + - "8443:8443" + +volumes: + spire-server-data: + spire-server-socket: diff --git a/examples/tutorial/idp/docker-compose.yml b/examples/tutorial/idp/docker-compose.yml index 09809617..de5eef81 100644 --- a/examples/tutorial/idp/docker-compose.yml +++ b/examples/tutorial/idp/docker-compose.yml @@ -29,6 +29,11 @@ services: # grant. If a later Keycloak drops these from defaults, re-add them here. volumes: - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + # A second realm = a second issuer, for the multi-issuer module (17). + # --import-realm loads every *.json here; modules 0–16 ignore it. + # (Named *-export.json, not *-realm.json, so Keycloak's dir-import + # doesn't require the file prefix to equal the realm name.) + - ./partner-export.json:/opt/keycloak/data/import/partner-export.json:ro healthcheck: # Hit the realm's OIDC discovery document. Once the realm is imported and # the server is serving, this returns 200. diff --git a/examples/tutorial/idp/partner-export.json b/examples/tutorial/idp/partner-export.json new file mode 100644 index 00000000..cb80ddc2 --- /dev/null +++ b/examples/tutorial/idp/partner-export.json @@ -0,0 +1,86 @@ +{ + "realm": "cpex-partner", + "enabled": true, + "accessTokenLifespan": 3600, + "roles": { + "realm": [ + { "name": "hr" }, + { "name": "engineer" }, + { "name": "security" } + ] + }, + "clients": [ + { + "clientId": "cpex-partner-app", + "name": "Partner org's app (a second trusted issuer)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "protocol": "openid-connect", + "protocolMappers": [ + { + "name": "audience-cpex-tutorial", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "cpex-tutorial", + "included.custom.audience": "", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "realm-roles-flat", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "usermodel.realmRoleMapping.rolePrefix": "" + } + }, + { + "name": "permissions-attr", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "permissions", + "claim.name": "permissions", + "jsonType.label": "String", + "multivalued": "true", + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "pat", + "enabled": true, + "emailVerified": true, + "email": "pat@partner.example", + "firstName": "Pat", + "lastName": "Partner", + "attributes": { + "permissions": ["view_ssn"], + "team": ["partner-hr"] + }, + "credentials": [ + { "type": "password", "value": "pat", "temporary": false } + ], + "realmRoles": ["hr"] + } + ] +} diff --git a/examples/tutorial/idp/realm-export.json b/examples/tutorial/idp/realm-export.json index cc46c553..6e4d9554 100644 --- a/examples/tutorial/idp/realm-export.json +++ b/examples/tutorial/idp/realm-export.json @@ -188,6 +188,32 @@ } } ] + }, + { + "clientId": "cpex-agent", + "name": "Example Agent (calls in as an OAuth client, subject: client)", + "enabled": true, + "publicClient": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "secret": "agent-dev-secret", + "protocol": "openid-connect", + "attributes": {}, + "protocolMappers": [ + { + "name": "audience-cpex-gateway", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "cpex-gateway", + "included.custom.audience": "", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] } ], "users": [ diff --git a/examples/tutorial/idp/spire/oidc/oidc-discovery-provider.conf b/examples/tutorial/idp/spire/oidc/oidc-discovery-provider.conf new file mode 100644 index 00000000..bf9af894 --- /dev/null +++ b/examples/tutorial/idp/spire/oidc/oidc-discovery-provider.conf @@ -0,0 +1,15 @@ +# SPIRE OIDC Discovery Provider for the CPEX tutorial (module 16). +# +# Publishes SPIRE's JWKS + an OIDC discovery document so Keycloak (and CPEX's +# jwt-workload resolver) can validate JWT-SVIDs against SPIRE's signing keys. +# Reads the trust bundle from the server over its shared admin socket. +# +# NEVER use this configuration (insecure_addr, no TLS) in production. +log_level = "info" +domains = ["spire-oidc", "localhost"] +insecure_addr = "0.0.0.0:8443" +set_key_use = true + +server_api { + address = "unix:///tmp/spire-server/private/api.sock" +} diff --git a/examples/tutorial/idp/spire/server/server.conf b/examples/tutorial/idp/spire/server/server.conf new file mode 100644 index 00000000..34226f33 --- /dev/null +++ b/examples/tutorial/idp/spire/server/server.conf @@ -0,0 +1,40 @@ +# SPIRE server for the CPEX tutorial's workload-identity module (16). +# +# Minimal, dev-only: mints JWT-SVIDs directly off the server (no agent, no +# node/workload attestation). A genuine SPIRE-signed ES256 SVID against +# SPIRE's real JWKS, with a two-container footprint (server + oidc). +# +# NEVER use this configuration in production. +server { + bind_address = "0.0.0.0" + bind_port = "8082" # NOT 8081 — Keycloak owns 8081. + trust_domain = "cpex.tutorial" + data_dir = "/opt/spire/data/server" + log_level = "INFO" + + ca_ttl = "24h" + default_x509_svid_ttl = "1h" + default_jwt_svid_ttl = "1h" + + # The `iss` SPIRE stamps into JWT-SVIDs. Keycloak validates SVIDs + # against this issuer's JWKS (published by the oidc provider below). + jwt_issuer = "http://spire-oidc:8443" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/opt/spire/data/server/datastore.sqlite3" + } + } + KeyManager "disk" { + plugin_data { + keys_path = "/opt/spire/data/server/keys.json" + } + } + # Present so the server boots; we mint SVIDs directly, no nodes attest. + NodeAttestor "join_token" { + plugin_data {} + } +} diff --git a/examples/tutorial/idp/spire/setup-spiffe.sh b/examples/tutorial/idp/spire/setup-spiffe.sh new file mode 100755 index 00000000..06c2ef02 --- /dev/null +++ b/examples/tutorial/idp/spire/setup-spiffe.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Configure the tutorial Keycloak to trust SPIRE and bind the agent's SPIFFE +# ID — the one-time setup for module 16 (Workload identity / SVID). +# +# Run it AFTER bringing up the SPIRE overlay: +# docker compose -f idp/docker-compose.yml -f idp/docker-compose.spire.yml up -d +# ./idp/spire/setup-spiffe.sh +# +# It is idempotent — safe to re-run. It applies two things via the admin API +# (kept OUT of realm-export.json so modules 0–15, on the base Keycloak, never +# see SPIFFE config): +# +# 1. a SPIFFE identity provider (providerId: spiffe) that validates JWT-SVIDs +# from trust domain spiffe://cpex.tutorial against SPIRE's JWKS. +# 2. a `federated-jwt` client (hr-copilot-agent) bound to the agent's SPIFFE +# ID, so presenting that SVID as a client_assertion authenticates the +# agent. An audience mapper puts cpex-gateway in its token so the gateway +# can exchange it (leg 2). +set -euo pipefail + +KC="${KC:-http://localhost:8081}" +REALM="${REALM:-cpex-tutorial}" +ADMIN="${ADMIN:-admin}" +ADMIN_PW="${ADMIN_PW:-admin}" +IDP_ALIAS="spiffe" +TRUST_DOMAIN="spiffe://cpex.tutorial" +BUNDLE_ENDPOINT="http://spire-oidc:8443/keys" +CLIENT_ID="hr-copilot-agent" +EXPECTED_SUB="spiffe://cpex.tutorial/agent/hr-copilot" +EXCHANGE_CLIENT_AUD="cpex-gateway" + +echo "-> obtaining admin token" +TOKEN=$(curl -sf -X POST "$KC/realms/master/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=admin-cli \ + -d username="$ADMIN" -d password="$ADMIN_PW" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + +# --- 1. SPIFFE identity provider ------------------------------------------- +read -r -d '' IDP_JSON < updating existing SPIFFE IdP '$IDP_ALIAS'" + curl -sf -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$KC/admin/realms/$REALM/identity-provider/instances/$IDP_ALIAS" -d "$IDP_JSON" +else + echo "-> creating SPIFFE IdP '$IDP_ALIAS'" + curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$KC/admin/realms/$REALM/identity-provider/instances" -d "$IDP_JSON" +fi + +# --- 2. federated-jwt client bound to the SVID ----------------------------- +read -r -d '' CLIENT_JSON < removing existing client '$CLIENT_ID' before recreate" + curl -sf -X DELETE -H "Authorization: Bearer $TOKEN" "$KC/admin/realms/$REALM/clients/$CID" +fi +echo "-> creating federated-jwt client '$CLIENT_ID' (sub=$EXPECTED_SUB, aud+=$EXCHANGE_CLIENT_AUD)" +curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + "$KC/admin/realms/$REALM/clients" -d "$CLIENT_JSON" + +echo +echo "-> done. Keycloak now authenticates $EXPECTED_SUB by its SVID and can exchange its token for cpex-gateway audiences." diff --git a/examples/tutorial/policies/attributes/controls.yaml b/examples/tutorial/policies/attributes/controls.yaml new file mode 100644 index 00000000..39536ad8 --- /dev/null +++ b/examples/tutorial/policies/attributes/controls.yaml @@ -0,0 +1,17 @@ +# Operator-maintained facts for module 18 — loaded into the `data.*` tree. +# +# These are decisions an operator makes and maintains alongside the deployment, +# carried by no token and living in no application: which tools are currently +# enabled, and the org's data region. Policy reads them per request; it does +# not compute them. Everything lives under a top-level `data:` mapping. +data: + org: + # The region this deployment's data must stay in. + data_region: eu + controls: + # An operator's per-tool kill switch — flip a tool off without touching + # policy or code. Policy reads these facts and decides. + tools: + get_compensation: { enabled: false } # frozen by operations + search_repos: { enabled: true } + send_email: { enabled: true } diff --git a/examples/tutorial/policies/m11.yaml b/examples/tutorial/policies/m11.yaml new file mode 100644 index 00000000..d67464e4 --- /dev/null +++ b/examples/tutorial/policies/m11.yaml @@ -0,0 +1,64 @@ +# Module 11, Organizing policy (Groups) +# +# By now every route repeats the same `authentication: [keycloak]` line — the +# identity resolver is the same everywhere, only the per-route authorization +# differs. A `group` factors that shared setup out once, and routes join it +# with `groups:`. +# +# Here the `identified` group holds the one thing every route needs — resolve +# the caller's token with `keycloak`. Each route joins it and adds only its +# own authorization. Change the resolver once (add a claim mapper, a second +# issuer) and every route that joins the group inherits it. +# +# `groups:` is sugar over tags: `groups: identified` is exactly +# `meta: { tags: [identified] }`. A route that names a group that doesn't +# exist is rejected at load, so a typo can't silently leave a route +# unauthenticated. + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: Authorization + 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 # localhost speaks http; never set this in production + refresh_secs: 600 + leeway_seconds: 60 + +# One reusable bundle. Written once; joined by every route below. +groups: + identified: + authentication: + - keycloak + +routes: + # HR-only. Joins `identified` for the resolver, then requires the hr role. + - tool: get_compensation + groups: identified + authorization: + pre_invocation: + - "require(role.hr)" + + # Engineering-only. Same resolver from the group; a different requirement. + - tool: search_repos + groups: identified + authorization: + pre_invocation: + - "require(role.engineer)" + + # Open to any authenticated caller. Still joins `identified` — that is what + # resolves the token in the first place — but adds no role requirement. + - tool: send_email + groups: identified + authorization: + pre_invocation: + - "require(authenticated)" diff --git a/examples/tutorial/policies/m12.yaml b/examples/tutorial/policies/m12.yaml new file mode 100644 index 00000000..e16eb64e --- /dev/null +++ b/examples/tutorial/policies/m12.yaml @@ -0,0 +1,69 @@ +# Module 12, Delegation subjects (who the downstream call speaks for) +# +# Module 6 minted a downstream token on behalf of the caller. But a delegation +# can speak for one of several principals, chosen with `subject:`: +# +# subject: user on behalf of the caller (the default). Exchanges +# the caller's token — so it needs a caller. +# subject: this_workload as the gateway itself. Uses the gateway's own +# client credentials (RFC 6749 client_credentials) — +# no caller token required. +# +# The mode is DERIVED from `subject`, never declared, so a route can't claim +# on-behalf-of-user while handing over some other credential. +# +# Two routes make the difference observable: +# - get_compensation exchanges the CALLER's token (subject: user). An +# anonymous request has no token to exchange, so delegation fails. +# - search_repos calls as the GATEWAY (subject: this_workload). It works +# with no caller at all, because the gateway holds the downstream +# credential. + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + 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] + 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)" diff --git a/examples/tutorial/policies/m13.yaml b/examples/tutorial/policies/m13.yaml new file mode 100644 index 00000000..50b3a4e2 --- /dev/null +++ b/examples/tutorial/policies/m13.yaml @@ -0,0 +1,65 @@ +# Module 13, Delegation subject: client (the agent scopes its OWN token). +# +# Modules 6 and 12 minted a downstream token on behalf of a signed-in *user*. +# But sometimes the caller is not a person at all: an agent authenticates to +# the IdP as its own OAuth client (client_credentials) and arrives holding a +# normal bearer token that speaks for itself, not for any human. +# +# subject: client scope the CALLER's own client token. It's a one-leg +# RFC 8693 exchange — the agent already did the +# authenticate leg upstream, so CPEX only narrows the +# token to the downstream audience. +# +# This is different from `subject: this_workload` (module 12): there the +# GATEWAY mints a fresh token from its own credentials and no caller token is +# read. Here the AGENT presents a token and CPEX scopes THAT — so, like +# `subject: user`, it needs an inbound credential, just a client one instead +# of a user one. +# +# See the cookbook, Recipe 5 (Scope a token the agent already holds). + +plugins: + # Resolve the agent's inbound token into the CLIENT slot. `role: client` is + # what routes it to `client` (not `subject`); the delegation below then + # selects it with `subject: client`. The token is aud'd to cpex-gateway + # (the enforcement point), so this resolver trusts that audience. + - name: keycloak-agent + kind: identity/jwt + hooks: [identity.resolve] + config: + role: client + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-gateway] + 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] + 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: + # The agent scopes its own client token down to the workday-api audience. + # `subject: client` exchanges the CALLER's client token — so, like + # subject: user, an anonymous request has nothing to exchange and fails. + - tool: search_repos + authentication: + - keycloak-agent + authorization: + pre_invocation: + - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: client)" + - "require(delegation.granted)" diff --git a/examples/tutorial/policies/m14.yaml b/examples/tutorial/policies/m14.yaml new file mode 100644 index 00000000..2fa48239 --- /dev/null +++ b/examples/tutorial/policies/m14.yaml @@ -0,0 +1,52 @@ +# Module 14, Passthrough (forward the caller's token, mint nothing). +# +# Modules 6, 12 and 13 all MINTED a downstream token — an RFC 8693 exchange +# that narrows or re-subjects the credential. But sometimes the right move is +# to mint nothing at all: the caller already holds a token that is correct for +# the downstream (the "agent-brokered" case — the agent authenticated to the +# IdP itself and handed CPEX a ready token). Then CPEX just validates it +# inbound and lets the call through, and the caller's own token is what flows +# downstream. +# +# Passthrough is therefore the ZERO-leg case, defined by what's ABSENT: there +# is no `delegate(...)` step. Compare: +# +# no delegate (this module) forward the caller's token unchanged +# delegate(subject: …) (modules 6/13) mint a fresh, scoped token +# +# The trade-off is scope. A minted token is narrowed to one audience, so a +# leak is contained. A forwarded token is whatever the caller brought — as +# broad as it was issued. Choose passthrough only when that token is already +# scoped for the downstream; reach for delegation when it isn't. +# +# See the cookbook, Recipe 4 (Forward a token the caller already has). + +plugins: + # A plain inbound resolver (as in module 2). Its only job here is to + # validate the caller's token; there is no delegator plugin at all. + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: Authorization + 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 + +routes: + # Passthrough: validate the caller, authorize, and forward. No delegate + # step, so the token the caller presented is the token that flows on. + - tool: search_repos + authentication: + - keycloak + authorization: + pre_invocation: + - "require(authenticated)" diff --git a/examples/tutorial/policies/m15.yaml b/examples/tutorial/policies/m15.yaml new file mode 100644 index 00000000..ae3c8654 --- /dev/null +++ b/examples/tutorial/policies/m15.yaml @@ -0,0 +1,93 @@ +# Module 15, Dual-principal delegation (who authorized vs. who acted). +# +# So far a delegation has spoken for ONE principal. But the common agentic +# shape has two: a human decides, and an agent carries the call out. You want +# the minted token to speak for the user (`sub`) AND to name the agent that +# performed the action (`act`). RFC 8693 calls this delegation (as opposed to +# impersonation, which records only the subject). +# +# subject: user the minted token speaks FOR the user (whose authority) +# actor: client the calling agent is named as the ACTING party +# +# Both credentials arrive on every call, on different headers: +# - the human's token on X-User-Token (resolved role: user -> subject) +# - the agent's token on Authorization (resolved role: client -> client) +# +# `actor:` only pairs with an on-behalf-of subject (`user` or `client`). It is +# rejected at load with `subject: this_workload` / `caller_workload`, so a +# route can't quietly ask for an actor the grant can't carry (see module code). +# +# See the cookbook, Recipe 6 (User acting through an agent, agent named). + +plugins: + # The human, on X-User-Token -> subject slot. + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: X-User-Token + 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 + + # The agent, on Authorization -> client slot (the acting party). + - name: jwt-agent + kind: identity/jwt + hooks: [identity.resolve] + config: + role: client + header: Authorization + claim_mapper: standard + trusted_issuers: + - issuer: http://localhost:8081/realms/cpex-tutorial + audiences: [cpex-gateway] + 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] + 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: + # The user authorizes (require role.hr on the SUBJECT), the token is minted + # on behalf of the user, and the agent is named as the actor. CPEX puts the + # actor on the wire per RFC 8693 delegation; whether `act` lands in the + # minted token is the token service's call (Keycloak drops it — see the doc). + - tool: get_compensation + authentication: + - jwt-user + - jwt-agent + authorization: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user, actor: client)" + - "require(delegation.granted)" + + # The guardrail: `actor:` only pairs with an on-behalf-of subject. Naming an + # actor while the subject is `this_workload` (a client_credentials grant that + # carries no actor_token) is a mistake, and CPEX denies it when the step runs + # rather than silently dropping the actor. + - tool: sync_index + authorization: + pre_invocation: + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: this_workload, actor: client)" diff --git a/examples/tutorial/policies/m16.yaml b/examples/tutorial/policies/m16.yaml new file mode 100644 index 00000000..9ceadb23 --- /dev/null +++ b/examples/tutorial/policies/m16.yaml @@ -0,0 +1,66 @@ +# Module 16, Workload identity (the agent proves itself with a SPIFFE SVID). +# +# The caller here is neither a human nor an OAuth client with a secret: it is a +# workload with a SPIFFE identity. It presents its JWT-SVID — an ES256 JWT +# signed by SPIRE (not the IdP) — on X-Workload-Token. CPEX validates it +# against SPIRE's JWKS and lands it in the `caller_workload` slot. +# +# An SVID is a SPIFFE identity credential, NOT an OAuth token: it can't be +# forwarded downstream or used as an exchange subject as-is. So `subject: +# caller_workload` runs TWO legs: +# leg 1 present the SVID as an RFC 8693/7523 client_assertion +# (type ...:jwt-spiffe) to authenticate the agent as its IdP client, +# and receive an ordinary IdP token; +# leg 2 exchange that token for the scoped downstream token. +# +# The agent holds no standing entitlement to the target — CPEX brokers the +# scope-up with its OWN gateway credential (leg 2). A compromised agent can +# prove who it is but cannot mint the downstream token itself. +# +# See the cookbook, Recipe 2 (Agent acting as itself, by its SPIFFE SVID). +# Needs the SPIRE overlay + setup — see idp/docker-compose.spire.yml. + +plugins: + # The workload SVID, on X-Workload-Token -> caller_workload slot. Validated + # against SPIRE's JWKS (the spire-oidc discovery provider on :8443), ES256, + # with the SVID's audience = the tutorial realm issuer (the draft requires + # the SVID's sole audience to be the authorization server it authenticates to). + - name: jwt-workload + kind: identity/jwt + hooks: [identity.resolve] + config: + role: caller_workload + header: X-Workload-Token + claim_mapper: standard + trusted_issuers: + - issuer: http://spire-oidc:8443 + audiences: [http://localhost:8081/realms/cpex-tutorial] + algorithms: [ES256] + decoding_key: + kind: jwks_url + url: http://localhost:8443/keys + insecure_http: true + leeway_seconds: 60 + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens] + 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: + # The agent authenticates by SVID alone (no user/client token) and CPEX + # brokers a github-api-scoped token from it via the two-leg exchange. + - tool: search_repos + authentication: + - jwt-workload + authorization: + pre_invocation: + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: caller_workload)" + - "require(delegation.granted)" diff --git a/examples/tutorial/policies/m17.yaml b/examples/tutorial/policies/m17.yaml new file mode 100644 index 00000000..83108829 --- /dev/null +++ b/examples/tutorial/policies/m17.yaml @@ -0,0 +1,50 @@ +# Module 17, Multi-issuer identity (trust more than one IdP). +# +# Every module so far trusted ONE issuer. But a real enforcement point often +# fronts users from several identity providers at once: your own workforce IdP +# plus a partner org's, say. CPEX resolves a token by matching its `iss` claim +# to the right trusted issuer and validating it against THAT issuer's JWKS — +# so a single resolver can accept both, each on its own keys. +# +# Here one `keycloak` resolver trusts two realms (two issuers in the same +# Keycloak, but they could be entirely separate IdPs): the home realm +# `cpex-tutorial` (alice) and a partner realm `cpex-partner` (pat). A token +# whose issuer is in neither list is rejected with `auth.untrusted_issuer`. + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: Authorization + claim_mapper: standard + trusted_issuers: + # Home issuer — your own workforce realm. + - 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 + # Partner issuer — a different realm, validated on ITS OWN keys. + - issuer: http://localhost:8081/realms/cpex-partner + audiences: [cpex-tutorial] + algorithms: [RS256] + decoding_key: + kind: jwks_url + url: http://localhost:8081/realms/cpex-partner/protocol/openid-connect/certs + insecure_http: true + leeway_seconds: 60 + +routes: + # One route, one rule. It doesn't care WHICH issuer vouched for the caller — + # only that some trusted one did and the resolved subject has role.hr. + - tool: get_compensation + authentication: + - keycloak + authorization: + pre_invocation: + - "require(role.hr)" diff --git a/examples/tutorial/policies/m18.yaml b/examples/tutorial/policies/m18.yaml new file mode 100644 index 00000000..e3cdfade --- /dev/null +++ b/examples/tutorial/policies/m18.yaml @@ -0,0 +1,59 @@ +# Module 18, Static attributes (operator-maintained facts in the `data.*` tree). +# +# Most attributes a predicate reads come from the request: the subject, its +# roles, session labels. But some facts are carried by nothing and belong in +# no token — they are operator decisions, known at configuration time: which +# region data must stay in, which tools are currently enabled. Those are +# STATIC attributes, provisioned from a file into the `data.*` namespace and +# read in policy exactly like `subject.*` or `role.*`. +# +# Here an operator's per-tool kill switch (data.controls.tools..enabled) +# decides whether a tool runs — without editing this policy or any code. The +# same caller reaches one tool and is refused another, and the difference is a +# fact in a data file, not anything about the request. + +global: + apl: + # Deep-merged, in order, into one `data.*` tree at startup. Paths are + # relative to where you run the example (the repo root). + attribute_files: + - examples/tutorial/policies/attributes/controls.yaml + +plugins: + - name: keycloak + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: Authorization + 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 + +routes: + # Enabled/disabled is an operator fact, read from data.* per request. Each + # route consults its own tool's flag; policy never hard-codes the answer. + - tool: get_compensation + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" + - when: "data.controls.tools.get_compensation.enabled == false" + do: + - "deny('get_compensation is disabled by operations', 'ops.tool_disabled')" + + - tool: search_repos + authentication: [keycloak] + authorization: + pre_invocation: + - "require(authenticated)" + - when: "data.controls.tools.search_repos.enabled == false" + do: + - "deny('search_repos is disabled by operations', 'ops.tool_disabled')" diff --git a/examples/tutorial/src/idp.rs b/examples/tutorial/src/idp.rs index e6e5e0fc..982a422f 100644 --- a/examples/tutorial/src/idp.rs +++ b/examples/tutorial/src/idp.rs @@ -80,6 +80,156 @@ pub async fn mint_token(username: &str, password: &str) -> Result String { + issuer() + .split_once("/realms/") + .map(|(base, _)| base.to_string()) + .unwrap_or_else(|| "http://localhost:8081".to_string()) +} + +/// Mint a user token from an arbitrary realm and client via the password +/// grant. The multi-issuer module (17) uses this to get a token from a SECOND +/// trusted issuer — the partner realm — alongside the home realm's, so one +/// resolver can validate both. +pub async fn mint_token_in_realm( + realm: &str, + client_id: &str, + username: &str, + password: &str, +) -> Result { + let endpoint = format!( + "{}/realms/{realm}/protocol/openid-connect/token", + base_url() + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + let resp = client + .post(&endpoint) + .form(&[ + ("grant_type", "password"), + ("client_id", client_id), + ("username", username), + ("password", password), + ("scope", "openid"), + ]) + .send() + .await + .map_err(|e| { + format!("could not reach the tutorial IdP at {endpoint} ({e}). Is it running?") + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!( + "token request for '{username}' in realm '{realm}' failed ({status}): {body}" + )); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("token response was not JSON: {e}"))?; + json.get("access_token") + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| "token response had no access_token".into()) +} + +/// Mint an access token for an OAuth *client* via the `client_credentials` +/// grant. Unlike [`mint_token`], there is no user: the token speaks for the +/// client's own service account. This is how an agent that authenticates to +/// the IdP as a registered client (not on behalf of a signed-in human) gets a +/// token — the inbound credential a `subject: client` delegation then scopes. +/// +/// `client_id` / `client_secret` identify the calling agent (e.g. the tutorial +/// realm's `cpex-agent`). Returns the raw JWT string. +pub async fn mint_client_token(client_id: &str, client_secret: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + let resp = client + .post(token_endpoint()) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ("client_secret", client_secret), + ]) + .send() + .await + .map_err(|e| { + format!( + "could not reach the tutorial IdP at {} ({e}).\n \ + Is it running? Start it with:\n \ + docker compose -f examples/tutorial/idp/docker-compose.yml up -d", + token_endpoint() + ) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!( + "client_credentials token request for '{client_id}' failed ({status}): {body}" + )); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("token response was not JSON: {e}"))?; + json.get("access_token") + .and_then(|v| v.as_str()) + .map(str::to_string) + .ok_or_else(|| "token response had no access_token".into()) +} + +/// Mint a SPIFFE JWT-SVID for `spiffe_id` off the tutorial's SPIRE server +/// (module 16). Unlike the OAuth token minters, this shells out to the running +/// `cpex-tutorial-spire-server` container — the SVID is signed by SPIRE, not +/// the IdP. Its audience is the tutorial realm issuer, as the SPIFFE +/// client-auth draft requires (leg 1 presents it to that realm). Needs the +/// SPIRE overlay up — see `idp/docker-compose.spire.yml`. +pub fn mint_svid(spiffe_id: &str) -> Result { + let out = std::process::Command::new("docker") + .args([ + "exec", + "cpex-tutorial-spire-server", + "/opt/spire/bin/spire-server", + "jwt", + "mint", + "-spiffeID", + spiffe_id, + "-audience", + &issuer(), + ]) + .output() + .map_err(|e| format!("could not run `docker` to mint an SVID: {e}"))?; + if !out.status.success() { + return Err(format!( + "`spire-server jwt mint` failed: {}\n Is the SPIRE overlay up?\n \ + docker compose -f examples/tutorial/idp/docker-compose.yml \ + -f examples/tutorial/idp/docker-compose.spire.yml up -d", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + // `jwt mint` prints the token; strip any surrounding whitespace/newlines. + let svid: String = String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .collect(); + if svid.is_empty() { + return Err("`spire-server jwt mint` produced no token".into()); + } + Ok(svid) +} + /// Poll the realm's discovery document until Keycloak answers or the /// deadline passes. Modules call this in `--check` mode so CI waits for /// the container to finish booting before minting tokens. diff --git a/examples/tutorial/src/mediate.rs b/examples/tutorial/src/mediate.rs index 08a2c753..1485cd4b 100644 --- a/examples/tutorial/src/mediate.rs +++ b/examples/tutorial/src/mediate.rs @@ -56,7 +56,14 @@ const ELICITATION_ID_HEADER: &str = "X-Policy-Elicitation-Id"; #[derive(Debug, Clone, Default)] pub struct Caller { /// Bearer token (a JWT from the tutorial IdP). `None` = anonymous. + /// Presented on the `Authorization` header. pub token: Option, + /// Additional credentials on other headers, as `(header, token)` pairs. + /// A dual-principal call (module 15) carries a second credential here — + /// e.g. the human on `X-User-Token` while the agent is on `Authorization` + /// — so two resolvers each read the header they're configured for and + /// fold their own identity slot (subject / client / caller_workload). + pub extra_credentials: Vec<(String, String)>, /// Session id for cross-request information flow. Session state only /// keys off this when the caller is also authenticated (has a subject). pub session_id: Option, @@ -78,11 +85,21 @@ impl Caller { pub fn with_token(token: impl Into) -> Self { Self { token: Some(token.into()), + extra_credentials: Vec::new(), session_id: None, elicitation_id: None, } } + /// Present an additional credential on a named header (module 15). + /// Chain it onto a [`Caller::with_token`] or [`Caller::anonymous`] call to + /// carry a second principal — e.g. `.with_credential("X-User-Token", jwt)` + /// so a route's user resolver and agent resolver each see their own token. + pub fn with_credential(mut self, header: impl Into, token: impl Into) -> Self { + self.extra_credentials.push((header.into(), token.into())); + self + } + /// Attach a session id so this call shares information-flow state with /// other calls using the same id (module 7). pub fn in_session(mut self, session_id: impl Into) -> Self { @@ -170,14 +187,28 @@ where // --- Step 1: resolve identity (skipped for anonymous callers). The // JWT plugin validates the token and returns a subject; we fold // that subject into the extensions the policy phases will read. --- - if let Some(token) = &caller.token { - let (id_result, id_bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - IdentityPayload::new(token.clone(), TokenSource::Bearer), - ext.clone(), - None, + if caller.token.is_some() || !caller.extra_credentials.is_empty() { + // One credential (modules 2–14) is passed bare, exactly as before. + // Multiple credentials (module 15+) are handed over as a header map + // so each resolver reads the header it's configured for; the JWT + // resolver falls back to the bare token only when no map is present. + let payload = if caller.extra_credentials.is_empty() { + IdentityPayload::new( + caller.token.clone().unwrap_or_default(), + TokenSource::Bearer, ) + } else { + let mut headers = std::collections::HashMap::new(); + if let Some(t) = &caller.token { + headers.insert("authorization".to_string(), t.clone()); + } + for (header, tok) in &caller.extra_credentials { + headers.insert(header.to_ascii_lowercase(), tok.clone()); + } + IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(headers) + }; + let (id_result, id_bg) = mgr + .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, ext.clone(), None) .await; id_bg.wait_for_background_tasks().await; if !id_result.continue_processing { From 8ef56af82b2aef96e93a57764e3b85a8b2f64445 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 15 Aug 2026 12:38:50 -0400 Subject: [PATCH 2/7] docs(tutorial): point cookbook links at the colon anchors Recipe headings on dev use colons (#156), so the double-hyphen anchors this branch was written against no longer resolve. Update the six anchors and the link text to match. Signed-off-by: Frederico Araujo --- docs/content/docs/tutorial/06-delegation.md | 2 +- docs/content/docs/tutorial/12-subjects.md | 2 +- docs/content/docs/tutorial/13-client.md | 6 +++--- docs/content/docs/tutorial/14-passthrough.md | 4 ++-- docs/content/docs/tutorial/15-dual-principal.md | 4 ++-- docs/content/docs/tutorial/16-workload.md | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/tutorial/06-delegation.md b/docs/content/docs/tutorial/06-delegation.md index 439f1b3b..fcfa5191 100644 --- a/docs/content/docs/tutorial/06-delegation.md +++ b/docs/content/docs/tutorial/06-delegation.md @@ -7,7 +7,7 @@ weight: 7 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. > -> **Cookbook recipe:** [Recipe 1 — User acting through an agent (on-behalf-of)]({{< relref "/docs/identity-delegation#recipe-1--user-acting-through-an-agent-on-behalf-of" >}}). +> **Cookbook recipe:** [Recipe 1: User acting through an agent (on-behalf-of)]({{< relref "/docs/identity-delegation#recipe-1-user-acting-through-an-agent-on-behalf-of" >}}). **Goal:** mint a narrow, downstream-scoped credential for a call with a real OAuth 2.0 token exchange (RFC 8693), instead of forwarding the caller's full token. diff --git a/docs/content/docs/tutorial/12-subjects.md b/docs/content/docs/tutorial/12-subjects.md index f5d43980..79a56758 100644 --- a/docs/content/docs/tutorial/12-subjects.md +++ b/docs/content/docs/tutorial/12-subjects.md @@ -7,7 +7,7 @@ weight: 13 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. > -> **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`). +> **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`). **Goal:** choose *whose* authority a minted downstream token carries — the caller, or the gateway itself. diff --git a/docs/content/docs/tutorial/13-client.md b/docs/content/docs/tutorial/13-client.md index 3c393786..60be8b1a 100644 --- a/docs/content/docs/tutorial/13-client.md +++ b/docs/content/docs/tutorial/13-client.md @@ -7,7 +7,7 @@ weight: 14 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. > -> **Cookbook recipe:** [Recipe 5 — Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5--scope-a-token-the-agent-already-holds-1-leg" >}}). +> **Cookbook recipe:** [Recipe 5: Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5-scope-a-token-the-agent-already-holds-1-leg" >}}). **Goal:** mint a downstream-scoped token when the caller is not a person but an **agent acting as itself** — an OAuth client that authenticated to the IdP on its own behalf. @@ -87,12 +87,12 @@ The agent's own token was narrowed to the `workday-api` audience and the call we {{< /details >}} {{< details "Is this one leg or two?" >}} -One. The agent did the authenticate leg upstream (it got its client token from the IdP itself), so CPEX performs only the **scope** leg — a plain RFC 8693 exchange. The two-leg path is [Recipe 2]({{< relref "/docs/identity-delegation#recipe-2--agent-acting-as-itself-by-its-spiffe-svid" >}}), where the agent presents a SPIFFE SVID and CPEX does both legs. Match the subject to what arrived: a JWT minted from an SVID is a `client`; the SVID itself is a `caller_workload`. +One. The agent did the authenticate leg upstream (it got its client token from the IdP itself), so CPEX performs only the **scope** leg — a plain RFC 8693 exchange. The two-leg path is [Recipe 2]({{< relref "/docs/identity-delegation#recipe-2-agent-acting-as-itself-by-its-spiffe-svid" >}}), where the agent presents a SPIFFE SVID and CPEX does both legs. Match the subject to what arrived: a JWT minted from an SVID is a `client`; the SVID itself is a `caller_workload`. {{< /details >}} ## Go deeper -- [Recipe 5 — Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5--scope-a-token-the-agent-already-holds-1-leg" >}}) for the reference version and the SVID-vs-token distinction. +- [Recipe 5: Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5-scope-a-token-the-agent-already-holds-1-leg" >}}) for the reference version and the SVID-vs-token distinction. - [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` contract. ## Next diff --git a/docs/content/docs/tutorial/14-passthrough.md b/docs/content/docs/tutorial/14-passthrough.md index e0afbbc6..ae54eb1e 100644 --- a/docs/content/docs/tutorial/14-passthrough.md +++ b/docs/content/docs/tutorial/14-passthrough.md @@ -7,7 +7,7 @@ weight: 15 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. > -> **Cookbook recipe:** [Recipe 4 — Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4--forward-a-token-the-caller-already-has-passthrough" >}}). +> **Cookbook recipe:** [Recipe 4: Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4-forward-a-token-the-caller-already-has-passthrough" >}}). **Goal:** recognize the case where the right move is to mint *nothing* — validate the caller's token and forward it unchanged. @@ -82,7 +82,7 @@ When the caller's token is broader than the downstream call needs. Forwarding it ## Go deeper -- [Recipe 4 — Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4--forward-a-token-the-caller-already-has-passthrough" >}}) for the reference version and where it fits among the delegation subjects. +- [Recipe 4: Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4-forward-a-token-the-caller-already-has-passthrough" >}}) for the reference version and where it fits among the delegation subjects. ## Next diff --git a/docs/content/docs/tutorial/15-dual-principal.md b/docs/content/docs/tutorial/15-dual-principal.md index 29d872d2..0d2d6b76 100644 --- a/docs/content/docs/tutorial/15-dual-principal.md +++ b/docs/content/docs/tutorial/15-dual-principal.md @@ -7,7 +7,7 @@ weight: 16 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. > -> **Cookbook recipe:** [Recipe 6 — User acting through an agent, with the agent named]({{< relref "/docs/identity-delegation#recipe-6--user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}). +> **Cookbook recipe:** [Recipe 6: User acting through an agent, with the agent named]({{< relref "/docs/identity-delegation#recipe-6-user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}). **Goal:** mint a token that speaks *for* the user and also *names the agent* that carried out the call — two principals on one exchange. @@ -97,7 +97,7 @@ The subject. Least-privilege scoping follows whose authority the token carries. ## Go deeper -- [Recipe 6 — dual-principal]({{< relref "/docs/identity-delegation#recipe-6--user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}) for the reference version and the full interop note. +- [Recipe 6: dual-principal]({{< relref "/docs/identity-delegation#recipe-6-user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}) for the reference version and the full interop note. - [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the `subject:` / `actor:` contract and valid combinations. ## Next diff --git a/docs/content/docs/tutorial/16-workload.md b/docs/content/docs/tutorial/16-workload.md index 516e99ff..89ca1e8a 100644 --- a/docs/content/docs/tutorial/16-workload.md +++ b/docs/content/docs/tutorial/16-workload.md @@ -7,7 +7,7 @@ weight: 17 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP **and** the SPIRE overlay. > -> **Cookbook recipe:** [Recipe 2 — Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2--agent-acting-as-itself-by-its-spiffe-svid" >}}). +> **Cookbook recipe:** [Recipe 2: Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2-agent-acting-as-itself-by-its-spiffe-svid" >}}). **Goal:** let a workload authenticate with a **SPIFFE SVID** — no user, no client secret — and have CPEX broker a scoped downstream token from it. @@ -110,7 +110,7 @@ Leg 2 — CPEX's own gateway credential — not the agent. The SVID proves ident ## Go deeper -- [Recipe 2 — Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2--agent-acting-as-itself-by-its-spiffe-svid" >}}) for the reference version and the IdP-support notes (tested on Keycloak 26.6, `spiffe:v1`). +- [Recipe 2: Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2-agent-acting-as-itself-by-its-spiffe-svid" >}}) for the reference version and the IdP-support notes (tested on Keycloak 26.6, `spiffe:v1`). - [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` contract. ## Next From cfda101de8a9f016195c7c84745db3cf02cee16f Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 15 Aug 2026 12:39:28 -0400 Subject: [PATCH 3/7] build: run the new tutorial modules in the check gate Modules 11-15, 17 and 18 join TUTORIAL_IDP_MODULES. Module 16 needs the SPIRE overlay and a one-time Keycloak setup, so it gets its own opt-in target instead. Signed-off-by: Frederico Araujo --- Makefile | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f0d71ef4..56e47b48 100644 --- a/Makefile +++ b/Makefile @@ -331,10 +331,16 @@ examples-run: examples-build tutorial-check-local # modules that need no infrastructure; `tutorial-check` additionally brings # up the tutorial Keycloak (docker compose) and runs the IdP-backed modules, # tearing the stack down afterward. CI runs `tutorial-check`. +# +# Module 16 needs the SPIRE overlay and a one-time Keycloak setup on top of +# the base stack, so it has its own opt-in target (`tutorial-check-spire`). TUTORIAL_IDP_COMPOSE = examples/tutorial/idp/docker-compose.yml +TUTORIAL_SPIRE_COMPOSE = examples/tutorial/idp/docker-compose.spire.yml TUTORIAL_NOIDP_MODULES = m01_hello m03_shaping m04_effects m09_custom_plugin m10_testing -TUTORIAL_IDP_MODULES = m02_identity m05_pdp m06_delegation m07_tainting m08_elicitation capstone +TUTORIAL_IDP_MODULES = m02_identity m05_pdp m06_delegation m07_tainting m08_elicitation \ + m11_groups m12_subjects m13_client m14_passthrough m15_dual_principal \ + m17_federation m18_attributes capstone .PHONY: tutorial-check-local tutorial-check-local: @@ -359,6 +365,24 @@ tutorial-check: tutorial-check-local @docker compose -f $(TUTORIAL_IDP_COMPOSE) down @echo "✅ Tutorial checks passed (incl. IdP-backed modules)" +# Module 16 only. Brings up the SPIRE overlay, trusts SPIRE in Keycloak, and +# runs the workload-identity module. Not part of the CI gate: it needs two +# extra containers and a Keycloak that speaks SPIFFE. +.PHONY: tutorial-check-spire +tutorial-check-spire: + @echo "→ starting tutorial IdP + SPIRE" + @docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) up -d + @echo "→ waiting for Keycloak realm to be ready" + @$(CARGO) run -q -p cpex-tutorial --example wait_for_idp || { \ + docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down; exit 1; } + @examples/tutorial/idp/spire/setup-spiffe.sh || { \ + docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down; exit 1; } + @echo "→ tutorial m16_workload --check" + @$(CARGO) run -q -p cpex-tutorial --example m16_workload -- --check || { \ + docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down; exit 1; } + @docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down + @echo "✅ Tutorial SPIRE check passed (module 16)" + .PHONY: tutorial-recordings tutorial-recordings: @examples/tutorial/recordings/record.sh From 33fe50aa0a4a633937bc86ed490f90167ba0ac17 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 15 Aug 2026 12:41:41 -0400 Subject: [PATCH 4/7] refactor(tutorial): share one token-request helper in the IdP harness The three minters differed only in endpoint and form fields, so fold the client build, status check and access_token extraction into one helper. mint_token_in_realm now carries the same 'is the IdP running?' hint as the others, and mint_svid trims its output instead of collapsing all whitespace, which could splice extra stdout into the token. Signed-off-by: Frederico Araujo --- examples/tutorial/src/idp.rs | 169 ++++++++++++------------------- examples/tutorial/src/mediate.rs | 50 +++++---- 2 files changed, 88 insertions(+), 131 deletions(-) diff --git a/examples/tutorial/src/idp.rs b/examples/tutorial/src/idp.rs index 982a422f..e6d6d44a 100644 --- a/examples/tutorial/src/idp.rs +++ b/examples/tutorial/src/idp.rs @@ -29,45 +29,34 @@ pub fn jwks_url() -> String { format!("{}/protocol/openid-connect/certs", issuer()) } -/// Mint an access token for a persona via the OAuth password grant against -/// the tutorial realm. `client_id` is the public `cpex-tutorial` client. +/// POST a form to a token endpoint and return its `access_token`. /// -/// Returns the raw JWT string, ready to hand to -/// [`crate::Caller::with_token`]. Errors carry a human-readable hint so a -/// reader whose IdP isn't up sees "is the stack running?" rather than a -/// bare connection error. -pub async fn mint_token(username: &str, password: &str) -> Result { +/// Every grant the tutorial uses (password, client_credentials, and the same +/// password grant against another realm) differs only in the form fields and +/// the endpoint, so they all come through here. `what` names the request in +/// the error, e.g. "token request for 'alice'". +async fn post_token_form( + endpoint: &str, + form: &[(&str, &str)], + what: &str, +) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .map_err(|e| format!("failed to build HTTP client: {e}"))?; - let resp = client - .post(token_endpoint()) - .form(&[ - ("grant_type", "password"), - ("client_id", "cpex-tutorial"), - ("username", username), - ("password", password), - ("scope", "openid"), - ]) - .send() - .await - .map_err(|e| { - format!( - "could not reach the tutorial IdP at {} ({e}).\n \ + let resp = client.post(endpoint).form(form).send().await.map_err(|e| { + format!( + "could not reach the tutorial IdP at {endpoint} ({e}).\n \ Is it running? Start it with:\n \ - docker compose -f examples/tutorial/idp/docker-compose.yml up -d", - token_endpoint() - ) - })?; + docker compose -f examples/tutorial/idp/docker-compose.yml up -d" + ) + })?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(format!( - "token request for '{username}' failed ({status}): {body}" - )); + return Err(format!("{what} failed ({status}): {body}")); } let json: serde_json::Value = resp @@ -80,6 +69,28 @@ pub async fn mint_token(username: &str, password: &str) -> Result Result { + post_token_form( + &token_endpoint(), + &[ + ("grant_type", "password"), + ("client_id", "cpex-tutorial"), + ("username", username), + ("password", password), + ("scope", "openid"), + ], + &format!("token request for '{username}'"), + ) + .await +} + /// Base URL of the tutorial Keycloak (everything before `/realms/...`), /// derived from [`issuer`] so a `CPEX_TUTORIAL_ISSUER` override carries through. fn base_url() -> String { @@ -90,8 +101,8 @@ fn base_url() -> String { } /// Mint a user token from an arbitrary realm and client via the password -/// grant. The multi-issuer module (17) uses this to get a token from a SECOND -/// trusted issuer — the partner realm — alongside the home realm's, so one +/// grant. The multi-issuer module (17) uses this to get a token from a second +/// trusted issuer, the partner realm, alongside the home realm's, so one /// resolver can validate both. pub async fn mint_token_in_realm( realm: &str, @@ -103,100 +114,50 @@ pub async fn mint_token_in_realm( "{}/realms/{realm}/protocol/openid-connect/token", base_url() ); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .map_err(|e| format!("failed to build HTTP client: {e}"))?; - - let resp = client - .post(&endpoint) - .form(&[ + post_token_form( + &endpoint, + &[ ("grant_type", "password"), ("client_id", client_id), ("username", username), ("password", password), ("scope", "openid"), - ]) - .send() - .await - .map_err(|e| { - format!("could not reach the tutorial IdP at {endpoint} ({e}). Is it running?") - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!( - "token request for '{username}' in realm '{realm}' failed ({status}): {body}" - )); - } - - let json: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("token response was not JSON: {e}"))?; - json.get("access_token") - .and_then(|v| v.as_str()) - .map(str::to_string) - .ok_or_else(|| "token response had no access_token".into()) + ], + &format!("token request for '{username}' in realm '{realm}'"), + ) + .await } /// Mint an access token for an OAuth *client* via the `client_credentials` /// grant. Unlike [`mint_token`], there is no user: the token speaks for the /// client's own service account. This is how an agent that authenticates to -/// the IdP as a registered client (not on behalf of a signed-in human) gets a -/// token — the inbound credential a `subject: client` delegation then scopes. +/// the IdP as a registered client, not on behalf of a signed-in human, gets a +/// token, the inbound credential a `subject: client` delegation then scopes. /// /// `client_id` / `client_secret` identify the calling agent (e.g. the tutorial /// realm's `cpex-agent`). Returns the raw JWT string. pub async fn mint_client_token(client_id: &str, client_secret: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .map_err(|e| format!("failed to build HTTP client: {e}"))?; - - let resp = client - .post(token_endpoint()) - .form(&[ + post_token_form( + &token_endpoint(), + &[ ("grant_type", "client_credentials"), ("client_id", client_id), ("client_secret", client_secret), - ]) - .send() - .await - .map_err(|e| { - format!( - "could not reach the tutorial IdP at {} ({e}).\n \ - Is it running? Start it with:\n \ - docker compose -f examples/tutorial/idp/docker-compose.yml up -d", - token_endpoint() - ) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!( - "client_credentials token request for '{client_id}' failed ({status}): {body}" - )); - } - - let json: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("token response was not JSON: {e}"))?; - json.get("access_token") - .and_then(|v| v.as_str()) - .map(str::to_string) - .ok_or_else(|| "token response had no access_token".into()) + ], + &format!("client_credentials token request for '{client_id}'"), + ) + .await } /// Mint a SPIFFE JWT-SVID for `spiffe_id` off the tutorial's SPIRE server /// (module 16). Unlike the OAuth token minters, this shells out to the running -/// `cpex-tutorial-spire-server` container — the SVID is signed by SPIRE, not +/// `cpex-tutorial-spire-server` container: the SVID is signed by SPIRE, not /// the IdP. Its audience is the tutorial realm issuer, as the SPIFFE /// client-auth draft requires (leg 1 presents it to that realm). Needs the -/// SPIRE overlay up — see `idp/docker-compose.spire.yml`. +/// SPIRE overlay up, see `idp/docker-compose.spire.yml`. +/// +/// Blocking on purpose: it runs once, before any concurrent work, and the +/// tutorial reads better without a second process API in play. pub fn mint_svid(spiffe_id: &str) -> Result { let out = std::process::Command::new("docker") .args([ @@ -220,10 +181,8 @@ pub fn mint_svid(spiffe_id: &str) -> Result { String::from_utf8_lossy(&out.stderr).trim() )); } - // `jwt mint` prints the token; strip any surrounding whitespace/newlines. - let svid: String = String::from_utf8_lossy(&out.stdout) - .split_whitespace() - .collect(); + // `jwt mint` prints the token on its own line. + let svid = String::from_utf8_lossy(&out.stdout).trim().to_string(); if svid.is_empty() { return Err("`spire-server jwt mint` produced no token".into()); } diff --git a/examples/tutorial/src/mediate.rs b/examples/tutorial/src/mediate.rs index 1485cd4b..3db9ee1d 100644 --- a/examples/tutorial/src/mediate.rs +++ b/examples/tutorial/src/mediate.rs @@ -59,10 +59,10 @@ pub struct Caller { /// Presented on the `Authorization` header. pub token: Option, /// Additional credentials on other headers, as `(header, token)` pairs. - /// A dual-principal call (module 15) carries a second credential here — - /// e.g. the human on `X-User-Token` while the agent is on `Authorization` - /// — so two resolvers each read the header they're configured for and - /// fold their own identity slot (subject / client / caller_workload). + /// A dual-principal call (module 15) carries a second credential here, + /// say the human on `X-User-Token` while the agent is on `Authorization`, + /// so two resolvers each read the header they're configured for and fold + /// their own identity slot (subject / client / caller_workload). pub extra_credentials: Vec<(String, String)>, /// Session id for cross-request information flow. Session state only /// keys off this when the caller is also authenticated (has a subject). @@ -85,15 +85,13 @@ impl Caller { pub fn with_token(token: impl Into) -> Self { Self { token: Some(token.into()), - extra_credentials: Vec::new(), - session_id: None, - elicitation_id: None, + ..Default::default() } } /// Present an additional credential on a named header (module 15). /// Chain it onto a [`Caller::with_token`] or [`Caller::anonymous`] call to - /// carry a second principal — e.g. `.with_credential("X-User-Token", jwt)` + /// carry a second principal, say `.with_credential("X-User-Token", jwt)` /// so a route's user resolver and agent resolver each see their own token. pub fn with_credential(mut self, header: impl Into, token: impl Into) -> Self { self.extra_credentials.push((header.into(), token.into())); @@ -188,24 +186,24 @@ where // JWT plugin validates the token and returns a subject; we fold // that subject into the extensions the policy phases will read. --- if caller.token.is_some() || !caller.extra_credentials.is_empty() { - // One credential (modules 2–14) is passed bare, exactly as before. - // Multiple credentials (module 15+) are handed over as a header map - // so each resolver reads the header it's configured for; the JWT - // resolver falls back to the bare token only when no map is present. - let payload = if caller.extra_credentials.is_empty() { - IdentityPayload::new( - caller.token.clone().unwrap_or_default(), - TokenSource::Bearer, - ) - } else { - let mut headers = std::collections::HashMap::new(); - if let Some(t) = &caller.token { - headers.insert("authorization".to_string(), t.clone()); - } - for (header, tok) in &caller.extra_credentials { - headers.insert(header.to_ascii_lowercase(), tok.clone()); - } - IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(headers) + // One credential (modules 2 to 14) is passed bare, exactly as before. + // Multiple credentials (module 15 on) are handed over as a header map + // so each resolver reads the header it's configured for. The bare form + // has to stay: the JWT resolver falls back to the raw token only when + // no map is present, so populating one unconditionally would break the + // single-resolver modules whose header is not `Authorization`. + let payload = match (&caller.token, caller.extra_credentials.is_empty()) { + (Some(token), true) => IdentityPayload::new(token.clone(), TokenSource::Bearer), + _ => { + let mut headers = std::collections::HashMap::new(); + if let Some(t) = &caller.token { + headers.insert("authorization".to_string(), t.clone()); + } + for (header, tok) in &caller.extra_credentials { + headers.insert(header.to_ascii_lowercase(), tok.clone()); + } + IdentityPayload::new(String::new(), TokenSource::Bearer).with_headers(headers) + }, }; let (id_result, id_bg) = mgr .invoke_named::(HOOK_IDENTITY_RESOLVE, payload, ext.clone(), None) From d727b4ab0faf096b8f89863ca4a2b8819b91a28d Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 15 Aug 2026 12:45:00 -0400 Subject: [PATCH 5/7] docs(tutorial): tighten module policies and example comments Correct the m15 header: an actor with subject: this_workload is denied when the step runs, not at load. Retarget m13 to github-api so the tool and its audience agree. Drop the send_email entry from controls.yaml, which no route reads. Trim the m14 header, cut comments that restate the scenario line, and drop em dashes to match the rest of the tutorial. Signed-off-by: Frederico Araujo --- examples/tutorial/examples/m11_groups.rs | 3 --- examples/tutorial/examples/m12_subjects.rs | 16 +++++++-------- examples/tutorial/examples/m13_client.rs | 14 +++++++------ examples/tutorial/examples/m14_passthrough.rs | 13 ++++++------ .../tutorial/examples/m15_dual_principal.rs | 2 +- examples/tutorial/examples/m16_workload.rs | 8 ++++---- examples/tutorial/examples/m17_federation.rs | 2 +- examples/tutorial/examples/m18_attributes.rs | 6 +++--- .../policies/attributes/controls.yaml | 5 ++--- examples/tutorial/policies/m11.yaml | 10 +++++----- examples/tutorial/policies/m12.yaml | 8 ++++---- examples/tutorial/policies/m13.yaml | 10 +++++----- examples/tutorial/policies/m14.yaml | 20 ++++++------------- examples/tutorial/policies/m15.yaml | 8 ++++---- examples/tutorial/policies/m16.yaml | 8 ++++---- examples/tutorial/policies/m17.yaml | 8 ++++---- examples/tutorial/policies/m18.yaml | 4 ++-- 17 files changed, 67 insertions(+), 78 deletions(-) diff --git a/examples/tutorial/examples/m11_groups.rs b/examples/tutorial/examples/m11_groups.rs index 851a3149..b3ea95d3 100644 --- a/examples/tutorial/examples/m11_groups.rs +++ b/examples/tutorial/examples/m11_groups.rs @@ -53,8 +53,6 @@ async fn main() { let evan = token("evan").await; let mut all_passed = true; - // The hr route: joined `identified` (keycloak resolves the token), then - // require(role.hr). ui::scenario( "alice (hr) → get_compensation (group resolves her token, require(role.hr) passes)", ); @@ -106,7 +104,6 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - // The open route: the group still resolves the token; no role required. ui::scenario( "alice (hr) → send_email (group resolves her token; require(authenticated) passes)", ); diff --git a/examples/tutorial/examples/m12_subjects.rs b/examples/tutorial/examples/m12_subjects.rs index 2f5812f6..e39c8838 100644 --- a/examples/tutorial/examples/m12_subjects.rs +++ b/examples/tutorial/examples/m12_subjects.rs @@ -12,9 +12,9 @@ // cargo run -p cpex-tutorial --example m12_subjects -- --check // // Two routes mint a downstream token differently: -// * get_compensation uses `subject: user` — it exchanges the CALLER's token, +// * get_compensation uses `subject: user`: it exchanges the CALLER's token, // so an anonymous request has nothing to exchange and delegation fails. -// * search_repos uses `subject: this_workload` — it mints a token as the +// * search_repos uses `subject: this_workload`: it mints a token as the // GATEWAY (client_credentials), so it works with no caller at all. use std::sync::Arc; @@ -53,8 +53,8 @@ async fn main() { let anon = Caller::anonymous(); let mut all_passed = true; - // subject: user — on behalf of the caller. Needs a caller token to exchange. - ui::scenario("alice → get_compensation (subject: user — exchanges alice's token)"); + // subject: user, on behalf of the caller. Needs a caller token to exchange. + ui::scenario("alice → get_compensation (subject: user, exchanges alice's token)"); let o = mediate( &mgr, &alice, @@ -67,7 +67,7 @@ async fn main() { all_passed &= ui::expect(&o, true); ui::scenario( - "anonymous → get_compensation (subject: user — no token to exchange, delegation fails)", + "anonymous → get_compensation (subject: user, no token to exchange, delegation fails)", ); let o = mediate( &mgr, @@ -80,9 +80,9 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - // subject: this_workload — as the gateway itself. No caller token needed. + // subject: this_workload, as the gateway itself. No caller token needed. ui::scenario( - "anonymous → search_repos (subject: this_workload — gateway mints via client_credentials)", + "anonymous → search_repos (subject: this_workload, gateway mints via client_credentials)", ); let o = mediate( &mgr, @@ -95,7 +95,7 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - ui::scenario("alice → search_repos (subject: this_workload — same result; the caller's identity is not used)"); + ui::scenario("alice → search_repos (subject: this_workload, same result: the caller's identity is not used)"); let o = mediate( &mgr, &alice, diff --git a/examples/tutorial/examples/m13_client.rs b/examples/tutorial/examples/m13_client.rs index 44655193..def22e6b 100644 --- a/examples/tutorial/examples/m13_client.rs +++ b/examples/tutorial/examples/m13_client.rs @@ -13,8 +13,8 @@ // // The caller is not a person: the agent authenticates to the IdP as its own // OAuth client (client_credentials) and arrives holding a token that speaks -// for itself. `subject: client` scopes THAT token down to the workday-api -// audience — a one-leg RFC 8693 exchange. Like subject: user, it needs an +// for itself. `subject: client` scopes THAT token down to the github-api +// audience, a one-leg RFC 8693 exchange. Like subject: user, it needs an // inbound credential, so an anonymous request has nothing to exchange. use std::sync::Arc; @@ -54,9 +54,9 @@ async fn main() { let anon = Caller::anonymous(); let mut all_passed = true; - // subject: client — scope the agent's OWN token. Needs an inbound client token. + // subject: client scopes the agent's OWN token. Needs an inbound client token. ui::scenario( - "agent (cpex-agent client) → search_repos (subject: client — scopes the agent's own token)", + "agent (cpex-agent client) → search_repos (subject: client, scopes the agent's own token)", ); let o = mediate( &mgr, @@ -69,7 +69,9 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - ui::scenario("anonymous → search_repos (subject: client — no client token to exchange, delegation fails)"); + ui::scenario( + "anonymous → search_repos (subject: client, no client token to exchange, delegation fails)", + ); let o = mediate( &mgr, &anon, @@ -81,6 +83,6 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - println!("`subject: client` scopes the CALLER's own client token — an agent acting as itself, not on behalf of a user."); + println!("`subject: client` scopes the CALLER's own client token: an agent acting as itself, not on behalf of a user."); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m14_passthrough.rs b/examples/tutorial/examples/m14_passthrough.rs index 1b57431f..8c37fa5f 100644 --- a/examples/tutorial/examples/m14_passthrough.rs +++ b/examples/tutorial/examples/m14_passthrough.rs @@ -12,7 +12,7 @@ // cargo run -p cpex-tutorial --example m14_passthrough -- --check // // The route has NO delegate step. CPEX validates the caller's token inbound -// and forwards it as-is — the zero-leg case. Contrast modules 6/13, which +// and forwards it as-is, the zero-leg case. Contrast modules 6/13, which // mint a fresh scoped token. Here the caller's own token flows downstream. use std::sync::Arc; @@ -51,9 +51,9 @@ async fn main() { let anon = Caller::anonymous(); let mut all_passed = true; - // A validated caller: no delegate step runs, so the token alice presented - // is what would flow downstream. CPEX validated and forwarded it. - ui::scenario("alice → search_repos (validated and forwarded — no token minted)"); + // No delegate step runs, so the token alice presented is what would flow + // downstream. + ui::scenario("alice → search_repos (validated and forwarded, no token minted)"); let o = mediate( &mgr, &alice, @@ -65,8 +65,7 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - // Passthrough still needs a valid inbound token: require(authenticated) - // gates it. An anonymous caller has nothing to forward. + // Passthrough still needs a valid inbound token to forward. ui::scenario("anonymous → search_repos (no token to forward, require(authenticated) denies)"); let o = mediate( &mgr, @@ -80,7 +79,7 @@ async fn main() { all_passed &= ui::expect(&o, false); println!( - "No delegate step: CPEX validated the caller's token and forwarded it unchanged — choose this only when that token is already scoped for the downstream." + "No delegate step: CPEX validated the caller's token and forwarded it unchanged. Choose this only when that token is already scoped for the downstream." ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m15_dual_principal.rs b/examples/tutorial/examples/m15_dual_principal.rs index 8eb5eece..ab7bbc82 100644 --- a/examples/tutorial/examples/m15_dual_principal.rs +++ b/examples/tutorial/examples/m15_dual_principal.rs @@ -92,7 +92,7 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, false); - // The guardrail: `actor:` with `subject: this_workload` is invalid — that + // The guardrail: `actor:` with `subject: this_workload` is invalid, since that // grant carries no actor_token. CPEX denies the step rather than silently // dropping the actor. No caller is needed; the check precedes the exchange. ui::scenario( diff --git a/examples/tutorial/examples/m16_workload.rs b/examples/tutorial/examples/m16_workload.rs index e22b6c75..b29ac595 100644 --- a/examples/tutorial/examples/m16_workload.rs +++ b/examples/tutorial/examples/m16_workload.rs @@ -15,7 +15,7 @@ // cargo run -p cpex-tutorial --example m16_workload -- --check // // The caller is a workload with a SPIFFE identity. It presents its ES256 -// JWT-SVID (minted by SPIRE) on X-Workload-Token — no user, no client secret. +// JWT-SVID (minted by SPIRE) on X-Workload-Token: no user, no client secret. // CPEX validates it against SPIRE's JWKS -> caller_workload, then runs the // two-leg delegation: leg 1 turns the SVID into an IdP token (jwt-spiffe // client_assertion), leg 2 exchanges that for a github-api-scoped token. @@ -44,7 +44,7 @@ async fn main() { mgr.initialize().await.expect("initialize"); // Mint the agent's SVID off SPIRE. This is the agent's identity credential - // — an ES256 JWT signed by SPIRE, audience = the tutorial realm issuer. + // an ES256 JWT signed by SPIRE, audience = the tutorial realm issuer. let svid = idp::mint_svid(SPIFFE_ID).unwrap_or_else(|e| { eprintln!("\x1b[31m{e}\x1b[0m"); std::process::exit(1); @@ -70,7 +70,7 @@ async fn main() { // No SVID: the route authenticates by the workload token alone, so an // anonymous request has no identity to broker from. - ui::scenario("anonymous → search_repos (no SVID, the workload route can't authenticate)"); + ui::scenario("anonymous → search_repos (no SVID, so nothing to broker from)"); let o = mediate( &mgr, &Caller::anonymous(), @@ -83,7 +83,7 @@ async fn main() { all_passed &= ui::expect(&o, false); println!( - "The agent proved itself with a SPIFFE SVID and never held the downstream token — CPEX brokered it in two legs." + "The agent proved itself with a SPIFFE SVID and never held the downstream token: CPEX brokered it in two legs." ); ui::finish_check(all_passed); } diff --git a/examples/tutorial/examples/m17_federation.rs b/examples/tutorial/examples/m17_federation.rs index 158c6ee8..e945d125 100644 --- a/examples/tutorial/examples/m17_federation.rs +++ b/examples/tutorial/examples/m17_federation.rs @@ -85,7 +85,7 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - ui::scenario("outsider (master realm — an untrusted issuer) → get_compensation (rejected)"); + ui::scenario("outsider (master realm, an untrusted issuer) → get_compensation (rejected)"); let o = mediate( &mgr, &outsider, diff --git a/examples/tutorial/examples/m18_attributes.rs b/examples/tutorial/examples/m18_attributes.rs index b9482aa8..5814b4db 100644 --- a/examples/tutorial/examples/m18_attributes.rs +++ b/examples/tutorial/examples/m18_attributes.rs @@ -13,7 +13,7 @@ // cargo run -p cpex-tutorial --example m18_attributes -- --check // // An operator's per-tool kill switch lives in a data file, loaded into the -// data.* tree. The same caller reaches one tool and is refused another — the +// data.* tree. The same caller reaches one tool and is refused another: the // difference is a fact in that file, not anything about the request. use std::sync::Arc; @@ -52,7 +52,7 @@ async fn main() { let mut all_passed = true; // Same caller, two tools. get_compensation is switched OFF in the data - // file, so it's refused — nothing about alice changed. + // file, so it's refused. Nothing about alice changed. ui::scenario("alice → get_compensation (data.controls says this tool is disabled)"); let o = mediate( &mgr, @@ -78,6 +78,6 @@ async fn main() { ui::print_outcome(&o); all_passed &= ui::expect(&o, true); - println!("Same caller, opposite outcomes — decided by an operator fact in a data file, read as data.*, not by anything in the request."); + println!("Same caller, opposite outcomes, decided by an operator fact in a data file, read as data.*, not by anything in the request."); ui::finish_check(all_passed); } diff --git a/examples/tutorial/policies/attributes/controls.yaml b/examples/tutorial/policies/attributes/controls.yaml index 39536ad8..12185369 100644 --- a/examples/tutorial/policies/attributes/controls.yaml +++ b/examples/tutorial/policies/attributes/controls.yaml @@ -1,4 +1,4 @@ -# Operator-maintained facts for module 18 — loaded into the `data.*` tree. +# Operator-maintained facts for module 18, loaded into the `data.*` tree. # # These are decisions an operator makes and maintains alongside the deployment, # carried by no token and living in no application: which tools are currently @@ -9,9 +9,8 @@ data: # The region this deployment's data must stay in. data_region: eu controls: - # An operator's per-tool kill switch — flip a tool off without touching + # An operator's per-tool kill switch: flip a tool off without touching # policy or code. Policy reads these facts and decides. tools: get_compensation: { enabled: false } # frozen by operations search_repos: { enabled: true } - send_email: { enabled: true } diff --git a/examples/tutorial/policies/m11.yaml b/examples/tutorial/policies/m11.yaml index d67464e4..31638497 100644 --- a/examples/tutorial/policies/m11.yaml +++ b/examples/tutorial/policies/m11.yaml @@ -1,11 +1,11 @@ # Module 11, Organizing policy (Groups) # -# By now every route repeats the same `authentication: [keycloak]` line — the -# identity resolver is the same everywhere, only the per-route authorization +# By now every route repeats the same `authentication: [keycloak]` line. The +# identity resolver is the same everywhere; only the per-route authorization # differs. A `group` factors that shared setup out once, and routes join it # with `groups:`. # -# Here the `identified` group holds the one thing every route needs — resolve +# Here the `identified` group holds the one thing every route needs: resolve # the caller's token with `keycloak`. Each route joins it and adds only its # own authorization. Change the resolver once (add a claim mapper, a second # issuer) and every route that joins the group inherits it. @@ -55,8 +55,8 @@ routes: pre_invocation: - "require(role.engineer)" - # Open to any authenticated caller. Still joins `identified` — that is what - # resolves the token in the first place — but adds no role requirement. + # Open to any authenticated caller. Still joins `identified`, which is what + # resolves the token in the first place, but adds no role requirement. - tool: send_email groups: identified authorization: diff --git a/examples/tutorial/policies/m12.yaml b/examples/tutorial/policies/m12.yaml index e16eb64e..21cf00f1 100644 --- a/examples/tutorial/policies/m12.yaml +++ b/examples/tutorial/policies/m12.yaml @@ -4,9 +4,9 @@ # can speak for one of several principals, chosen with `subject:`: # # subject: user on behalf of the caller (the default). Exchanges -# the caller's token — so it needs a caller. +# the caller's token, so it needs a caller. # subject: this_workload as the gateway itself. Uses the gateway's own -# client credentials (RFC 6749 client_credentials) — +# client credentials (RFC 6749 client_credentials); # no caller token required. # # The mode is DERIVED from `subject`, never declared, so a route can't claim @@ -49,7 +49,7 @@ plugins: 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 + # caller's token, so a request with no token has nothing to exchange and # delegation fails at require(delegation.granted). - tool: get_compensation authentication: @@ -60,7 +60,7 @@ routes: - "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 + # client credentials, so it needs no caller token at all: the enforcement # point, not the caller, holds the downstream authority. - tool: search_repos authorization: diff --git a/examples/tutorial/policies/m13.yaml b/examples/tutorial/policies/m13.yaml index 50b3a4e2..d4cad8ee 100644 --- a/examples/tutorial/policies/m13.yaml +++ b/examples/tutorial/policies/m13.yaml @@ -6,13 +6,13 @@ # normal bearer token that speaks for itself, not for any human. # # subject: client scope the CALLER's own client token. It's a one-leg -# RFC 8693 exchange — the agent already did the +# RFC 8693 exchange. The agent already did the # authenticate leg upstream, so CPEX only narrows the # token to the downstream audience. # # This is different from `subject: this_workload` (module 12): there the # GATEWAY mints a fresh token from its own credentials and no caller token is -# read. Here the AGENT presents a token and CPEX scopes THAT — so, like +# read. Here the AGENT presents a token and CPEX scopes THAT, so, like # `subject: user`, it needs an inbound credential, just a client one instead # of a user one. # @@ -53,13 +53,13 @@ plugins: insecure_http: true routes: - # The agent scopes its own client token down to the workday-api audience. - # `subject: client` exchanges the CALLER's client token — so, like + # The agent scopes its own client token down to the github-api audience. + # `subject: client` exchanges the CALLER's client token, so, like # subject: user, an anonymous request has nothing to exchange and fails. - tool: search_repos authentication: - keycloak-agent authorization: pre_invocation: - - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: client)" + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: client)" - "require(delegation.granted)" diff --git a/examples/tutorial/policies/m14.yaml b/examples/tutorial/policies/m14.yaml index 2fa48239..5e2e4702 100644 --- a/examples/tutorial/policies/m14.yaml +++ b/examples/tutorial/policies/m14.yaml @@ -1,23 +1,15 @@ # Module 14, Passthrough (forward the caller's token, mint nothing). # -# Modules 6, 12 and 13 all MINTED a downstream token — an RFC 8693 exchange -# that narrows or re-subjects the credential. But sometimes the right move is -# to mint nothing at all: the caller already holds a token that is correct for -# the downstream (the "agent-brokered" case — the agent authenticated to the -# IdP itself and handed CPEX a ready token). Then CPEX just validates it -# inbound and lets the call through, and the caller's own token is what flows -# downstream. -# -# Passthrough is therefore the ZERO-leg case, defined by what's ABSENT: there -# is no `delegate(...)` step. Compare: +# Passthrough is the zero-leg case, defined by what is ABSENT: there is no +# `delegate(...)` step. CPEX validates the caller's token inbound, authorizes +# the call, and that same token is what flows downstream. # # no delegate (this module) forward the caller's token unchanged # delegate(subject: …) (modules 6/13) mint a fresh, scoped token # -# The trade-off is scope. A minted token is narrowed to one audience, so a -# leak is contained. A forwarded token is whatever the caller brought — as -# broad as it was issued. Choose passthrough only when that token is already -# scoped for the downstream; reach for delegation when it isn't. +# The trade-off is scope: a forwarded token is as broad as it was issued, where +# a minted one is narrowed to a single audience. Forward only when the caller's +# token is already scoped for the downstream. # # See the cookbook, Recipe 4 (Forward a token the caller already has). diff --git a/examples/tutorial/policies/m15.yaml b/examples/tutorial/policies/m15.yaml index ae3c8654..83de1ca6 100644 --- a/examples/tutorial/policies/m15.yaml +++ b/examples/tutorial/policies/m15.yaml @@ -13,9 +13,9 @@ # - the human's token on X-User-Token (resolved role: user -> subject) # - the agent's token on Authorization (resolved role: client -> client) # -# `actor:` only pairs with an on-behalf-of subject (`user` or `client`). It is -# rejected at load with `subject: this_workload` / `caller_workload`, so a -# route can't quietly ask for an actor the grant can't carry (see module code). +# `actor:` only pairs with an on-behalf-of subject (`user` or `client`). With +# `subject: this_workload` / `caller_workload` the step is denied when it runs, +# so a route can't quietly ask for an actor the grant can't carry. # # See the cookbook, Recipe 6 (User acting through an agent, agent named). @@ -72,7 +72,7 @@ routes: # The user authorizes (require role.hr on the SUBJECT), the token is minted # on behalf of the user, and the agent is named as the actor. CPEX puts the # actor on the wire per RFC 8693 delegation; whether `act` lands in the - # minted token is the token service's call (Keycloak drops it — see the doc). + # minted token is the token service's call (Keycloak drops it, see the doc). - tool: get_compensation authentication: - jwt-user diff --git a/examples/tutorial/policies/m16.yaml b/examples/tutorial/policies/m16.yaml index 9ceadb23..a1a03927 100644 --- a/examples/tutorial/policies/m16.yaml +++ b/examples/tutorial/policies/m16.yaml @@ -1,8 +1,8 @@ # Module 16, Workload identity (the agent proves itself with a SPIFFE SVID). # # The caller here is neither a human nor an OAuth client with a secret: it is a -# workload with a SPIFFE identity. It presents its JWT-SVID — an ES256 JWT -# signed by SPIRE (not the IdP) — on X-Workload-Token. CPEX validates it +# workload with a SPIFFE identity. It presents its JWT-SVID, an ES256 JWT +# signed by SPIRE rather than the IdP, on X-Workload-Token. CPEX validates it # against SPIRE's JWKS and lands it in the `caller_workload` slot. # # An SVID is a SPIFFE identity credential, NOT an OAuth token: it can't be @@ -13,12 +13,12 @@ # and receive an ordinary IdP token; # leg 2 exchange that token for the scoped downstream token. # -# The agent holds no standing entitlement to the target — CPEX brokers the +# The agent holds no standing entitlement to the target: CPEX brokers the # scope-up with its OWN gateway credential (leg 2). A compromised agent can # prove who it is but cannot mint the downstream token itself. # # See the cookbook, Recipe 2 (Agent acting as itself, by its SPIFFE SVID). -# Needs the SPIRE overlay + setup — see idp/docker-compose.spire.yml. +# Needs the SPIRE overlay and its setup script, see idp/docker-compose.spire.yml. plugins: # The workload SVID, on X-Workload-Token -> caller_workload slot. Validated diff --git a/examples/tutorial/policies/m17.yaml b/examples/tutorial/policies/m17.yaml index 83108829..a91feead 100644 --- a/examples/tutorial/policies/m17.yaml +++ b/examples/tutorial/policies/m17.yaml @@ -3,7 +3,7 @@ # Every module so far trusted ONE issuer. But a real enforcement point often # fronts users from several identity providers at once: your own workforce IdP # plus a partner org's, say. CPEX resolves a token by matching its `iss` claim -# to the right trusted issuer and validating it against THAT issuer's JWKS — +# to the right trusted issuer and validating it against THAT issuer's JWKS, # so a single resolver can accept both, each on its own keys. # # Here one `keycloak` resolver trusts two realms (two issuers in the same @@ -20,7 +20,7 @@ plugins: header: Authorization claim_mapper: standard trusted_issuers: - # Home issuer — your own workforce realm. + # Home issuer: your own workforce realm. - issuer: http://localhost:8081/realms/cpex-tutorial audiences: [cpex-tutorial] algorithms: [RS256] @@ -29,7 +29,7 @@ plugins: url: http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/certs insecure_http: true leeway_seconds: 60 - # Partner issuer — a different realm, validated on ITS OWN keys. + # Partner issuer: a different realm, validated on ITS OWN keys. - issuer: http://localhost:8081/realms/cpex-partner audiences: [cpex-tutorial] algorithms: [RS256] @@ -40,7 +40,7 @@ plugins: leeway_seconds: 60 routes: - # One route, one rule. It doesn't care WHICH issuer vouched for the caller — + # One route, one rule. It doesn't care WHICH issuer vouched for the caller, # only that some trusted one did and the resolved subject has role.hr. - tool: get_compensation authentication: diff --git a/examples/tutorial/policies/m18.yaml b/examples/tutorial/policies/m18.yaml index e3cdfade..a9fccd82 100644 --- a/examples/tutorial/policies/m18.yaml +++ b/examples/tutorial/policies/m18.yaml @@ -2,13 +2,13 @@ # # Most attributes a predicate reads come from the request: the subject, its # roles, session labels. But some facts are carried by nothing and belong in -# no token — they are operator decisions, known at configuration time: which +# no token: they are operator decisions, known at configuration time, such as which # region data must stay in, which tools are currently enabled. Those are # STATIC attributes, provisioned from a file into the `data.*` namespace and # read in policy exactly like `subject.*` or `role.*`. # # Here an operator's per-tool kill switch (data.controls.tools..enabled) -# decides whether a tool runs — without editing this policy or any code. The +# decides whether a tool runs, without editing this policy or any code. The # same caller reaches one tool and is refused another, and the difference is a # fact in a data file, not anything about the request. From 0df4b79527904acaac021253e7710b0cf3b5da83 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 15 Aug 2026 12:50:18 -0400 Subject: [PATCH 6/7] docs(tutorial): tighten the identity and delegation modules Open each module on its own problem instead of a recap of the previous ones, cut the editorial asides, reserve bold for labels, and drop the em dashes the rest of the tutorial does not use. Fold module 14's trade-off and module 15's guardrail into the sections they belong to, shorten module 18's per-caller example and mark it as illustration rather than something the module runs, and note that module 18 must run from the repo root. Module 12 now points at modules 13, 15 and 16 for the subjects it used to call unsupported. Scale the time budget for eight extra modules, and link the recipe anchors and configuration sections that exist. Signed-off-by: Frederico Araujo --- docs/content/docs/tutorial/06-delegation.md | 1 + docs/content/docs/tutorial/11-groups.md | 24 +++++------ docs/content/docs/tutorial/12-subjects.md | 32 +++++++-------- docs/content/docs/tutorial/13-client.md | 38 +++++++++--------- docs/content/docs/tutorial/14-passthrough.md | 30 ++++++-------- .../docs/tutorial/15-dual-principal.md | 40 +++++++++---------- docs/content/docs/tutorial/16-workload.md | 38 +++++++++--------- docs/content/docs/tutorial/17-federation.md | 22 +++++----- docs/content/docs/tutorial/18-attributes.md | 34 ++++++++-------- docs/content/docs/tutorial/_index.md | 8 ++-- 10 files changed, 129 insertions(+), 138 deletions(-) diff --git a/docs/content/docs/tutorial/06-delegation.md b/docs/content/docs/tutorial/06-delegation.md index fcfa5191..b6163252 100644 --- a/docs/content/docs/tutorial/06-delegation.md +++ b/docs/content/docs/tutorial/06-delegation.md @@ -88,6 +88,7 @@ Keycloak mints the scoped token during the exchange, constrained by the requeste ## Go deeper - [Delegation]({{< relref "/docs/apl/delegation" >}}) for token exchange, capability reduction, and downstream verification. +- [Module 12: Delegation subjects]({{< relref "12-subjects" >}}) for the other principals a minted token can speak for. ## Next diff --git a/docs/content/docs/tutorial/11-groups.md b/docs/content/docs/tutorial/11-groups.md index f2769f6f..526ecb14 100644 --- a/docs/content/docs/tutorial/11-groups.md +++ b/docs/content/docs/tutorial/11-groups.md @@ -7,13 +7,13 @@ weight: 12 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. -**Goal:** factor the setup every route shares — here, the identity resolver — into one reusable **group**, so routes join it instead of repeating it. +**Goal:** factor the setup every route shares, here the identity resolver, into one reusable group that routes join instead of repeating. ## The problem By now you have written `authentication: [keycloak]` on route after route. The resolver is the same everywhere; only the per-route authorization differs. That repetition is a maintenance hazard: add a second issuer or a claim mapper later, and you have to change every route and hope you caught them all. -A **group** is a named, reusable bundle of policy — authentication steps, authorization steps, plugins — that routes opt into. Put the shared part in a group once; each route joins it and adds only what is specific to it. +A group is a named, reusable bundle of policy (authentication steps, authorization steps, plugins) that routes opt into. Put the shared part in a group once; each route joins it and adds only what is specific to it. ## Build it @@ -26,7 +26,7 @@ plugins: hooks: [identity.resolve] config: { ... as in module 2 ... } -# One reusable bundle — the resolver every route needs, written once. +# One reusable bundle: the resolver every route needs, written once. groups: identified: authentication: @@ -52,10 +52,8 @@ routes: The `identified` group carries the one thing all three routes share. Each route joins it and adds only its own authorization, so identity is resolved the same way everywhere while policy still decides each outcome per caller. -Two things worth knowing: - -- **`groups:` is sugar over tags.** `groups: identified` is exactly `meta: { tags: [identified] }` — a route joins a group by naming it. The `groups:` field just makes that membership a first-class, discoverable spelling. Runtime tags a host injects still join groups the same way. -- **An unknown group is a load error.** Join a group that isn't defined — a typo like `groups: identifed` — and the config is rejected at load, so a mistake can't silently leave a route unauthenticated. +- **`groups:` is sugar over tags.** `groups: identified` is exactly `meta: { tags: [identified] }`. The field just makes that membership a first-class, discoverable spelling, and a runtime tag a host injects joins the group the same way. +- **An unknown group is a load error.** Join a group that isn't defined, a typo like `groups: identifed`, and the config is rejected at load, so a mistake can't silently leave a route unauthenticated. ## Run it @@ -84,9 +82,9 @@ Every route resolved the caller's token through the same group, yet each outcome ## Try it -1. **Break a join.** Change one route's `groups:` to a name that doesn't exist (`groups: identifed`) and re-run. Expect: the config is rejected at load with an unknown-group error — the typo fails loudly instead of silently dropping authentication. -2. **Change the resolver once.** Add `leeway_seconds: 5` (or a second entry under `trusted_issuers:`) to the `keycloak` plugin. Every route that joins `identified` picks it up — you edited one place, not three. -3. **Tags are the same thing.** Replace `groups: identified` on a route with `meta: { tags: [identified] }` and re-run. Same result — `groups:` and a matching tag are the same membership. +1. **Break a join.** Change one route's `groups:` to a name that doesn't exist (`groups: identifed`) and re-run. Expect: the config is rejected at load with an unknown-group error, so the typo fails loudly instead of silently dropping authentication. +2. **Change the resolver once.** Add `leeway_seconds: 5` (or a second entry under `trusted_issuers:`) to the `keycloak` plugin. Every route that joins `identified` picks it up: you edited one place, not three. +3. **Tags are the same thing.** Replace `groups: identified` on a route with `meta: { tags: [identified] }` and re-run. Same result, because a group and a matching tag are the same membership. ## Checkpoint @@ -94,13 +92,13 @@ Every route resolved the caller's token through the same group, yet each outcome No. The route has no `authentication:` block, so it inherits the group's. Identity resolution stacks broad → narrow (global → group → route); a route joining `identified` runs the group's `keycloak` resolver without naming it again. {{< /details >}} -{{< details "Group or the reserved catch-all?" >}} -`groups:` are opt-in — a route joins one by name. That is different from applying a plugin to *every* request, which is a separate, always-on layer. Reach for a group when a *subset* of routes shares setup, as here. +{{< details "Group or the reserved `all` group?" >}} +Groups are opt-in: a route joins one by name. The reserved `all` group is the other case, applied to every request unconditionally. Reach for a named group when a *subset* of routes shares setup, as here. {{< /details >}} ## Go deeper -- [Configuration → Groups]({{< relref "/docs/configuration" >}}) for the full `groups:` schema, defaults, and how membership resolves. +- [Configuration]({{< relref "/docs/configuration" >}}#groups) for the full `groups:` schema, defaults, and how membership resolves. ## Next diff --git a/docs/content/docs/tutorial/12-subjects.md b/docs/content/docs/tutorial/12-subjects.md index 79a56758..0308f282 100644 --- a/docs/content/docs/tutorial/12-subjects.md +++ b/docs/content/docs/tutorial/12-subjects.md @@ -9,11 +9,11 @@ weight: 13 > > **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`). -**Goal:** choose *whose* authority a minted downstream token carries — the caller, or the gateway itself. +**Goal:** choose *whose* authority a minted downstream token carries: the caller's, or the gateway's own. ## The problem -Module 6 minted a token **on behalf of the caller**: it exchanged the caller's token for a scoped one. That is the right default when the agent acts for a signed-in user. But 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. +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. @@ -40,7 +40,7 @@ routes: ``` - **`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` / secret — the same `workday-oauth` plugin, no caller token read at all. The `search_repos` route deliberately has no `authentication:`, to make the point that this path needs no caller. +- **`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 @@ -49,25 +49,25 @@ cargo run -p cpex-tutorial --example m12_subjects ``` ``` -▸ alice → get_compensation (subject: user — exchanges alice's token) +▸ alice → get_compensation (subject: user, exchanges alice's token) ✓ ALLOWED { ... } -▸ anonymous → get_compensation (subject: user — no token to exchange, delegation fails) +▸ 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) +▸ 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) +▸ alice → search_repos (subject: this_workload, same result: the caller's identity is not used) ✓ ALLOWED { ... } ``` -The difference is stark: `subject: user` **needs a caller** — the anonymous request fails at the exchange with an empty token. `subject: this_workload` **needs no caller** — it succeeds anonymously, because the gateway holds the credential. +`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. **Give the anonymous caller a token.** Change the second scenario to send `alice` at `get_compensation`. Expect: it now succeeds — there is a token to exchange. -2. **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. This is exactly the choice `subject:` gives you. +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`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). Remove that mapper and the minted token won't carry the audience. ## Checkpoint @@ -77,18 +77,18 @@ The difference is stark: `subject: user` **needs a caller** — the anonymous re {{< /details >}} {{< details "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 deliberately no separate `mode:` key, so a route can't claim on-behalf-of-user while handing over the gateway's own credential. +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. {{< /details >}} ## Go deeper -The two remaining subjects need infrastructure this tutorial's Keycloak doesn't set up, but they follow the same rule — the subject picks the principal: +The same rule covers the other subjects, each with its own module: -- **`subject: client`** — the calling OAuth *client / app* acting as itself (its own token scoped down), rather than a human user. -- **`subject: caller_workload`** — the calling *agent* proving itself with a SPIFFE JWT-SVID, exchanged in two legs (client-assertion, then scope). Needs a SPIFFE issuer (SPIRE). -- **`actor:`** — record the calling agent in the RFC 8693 `act` claim alongside the user `sub`. Whether `act` appears depends on the token service; Keycloak's Standard Token Exchange does not emit it. +- **`subject: client`**, the calling OAuth client acting as itself, its own token scoped down: [module 13]({{< relref "13-client" >}}). +- **`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. +- **`actor:`**, recording the calling agent alongside the user `sub`: [module 15]({{< relref "15-dual-principal" >}}). -See the [Identity & Delegation cookbook]({{< relref "/docs/identity-delegation" >}}) for recipes covering all of these, and the [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` / `actor:` contract. +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. ## Next diff --git a/docs/content/docs/tutorial/13-client.md b/docs/content/docs/tutorial/13-client.md index 60be8b1a..fbbbe3f1 100644 --- a/docs/content/docs/tutorial/13-client.md +++ b/docs/content/docs/tutorial/13-client.md @@ -9,27 +9,25 @@ weight: 14 > > **Cookbook recipe:** [Recipe 5: Scope a token the agent already holds]({{< relref "/docs/identity-delegation#recipe-5-scope-a-token-the-agent-already-holds-1-leg" >}}). -**Goal:** mint a downstream-scoped token when the caller is not a person but an **agent acting as itself** — an OAuth client that authenticated to the IdP on its own behalf. +**Goal:** mint a downstream-scoped token when the caller is not a person but an agent acting as itself, an OAuth client that authenticated to the IdP on its own behalf. ## The problem -Modules 6 and 12 both assumed a signed-in human: `subject: user` exchanges the *caller's user token*. But plenty of calls have no human behind them and are not the gateway either — an agent authenticates to the IdP as its **own OAuth client** (the `client_credentials` grant) and arrives holding a token that speaks for *itself*. +Plenty of calls have no human behind them and are not the gateway either. An agent authenticates to the IdP as its own OAuth client (the `client_credentials` grant) and arrives holding a token that speaks for *itself*, where modules 6 and 12 assumed a signed-in human. -You still want least privilege at the boundary: narrow that broad client token to just the tool being called. That is `subject: client`. - -It sits between the two subjects you already know: +You still want least privilege at the boundary: narrow that broad client token to just the tool being called. That is `subject: client`, and it sits between the two subjects you know: | Subject | Whose token is exchanged? | Needs a caller token? | |---|---|---| | `user` (module 6, 12) | the signed-in user's | yes | -| **`client` (this module)** | **the calling agent's own** | **yes** | -| `this_workload` (module 12) | none — gateway mints its own | no | +| `client` (this module) | the calling agent's own | yes | +| `this_workload` (module 12) | none, the gateway mints its own | no | -Like `subject: user`, it scopes an **inbound** credential — so an anonymous request has nothing to exchange. Unlike `this_workload`, the authority is the *caller's*, not the gateway's. +Like `subject: user`, it scopes an *inbound* credential, so an anonymous request has nothing to exchange. Unlike `this_workload`, the authority is the caller's, not the gateway's. ## Build it -Two changes from module 12. First, resolve the agent's token into the **`client`** slot with `role: client`. Second, select it with `subject: client`. From [`policies/m13.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m13.yaml): +Two changes from module 12. First, resolve the agent's token into the `client` slot with `role: client`. Second, select it with `subject: client`. From [`policies/m13.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m13.yaml): ```yaml plugins: @@ -52,11 +50,11 @@ routes: authentication: [keycloak-agent] authorization: pre_invocation: - - "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: client)" + - "delegate(workday-oauth, target: github-api, audience: github-api, subject: client)" - "require(delegation.granted)" ``` -The agent authenticates upstream as the realm's `cpex-agent` client; CPEX validates that token, and the `delegate` step exchanges it — **one leg** (the scope), because the agent already did the authenticate leg itself. +The agent authenticates upstream as the realm's `cpex-agent` client; CPEX validates that token, and the `delegate` step exchanges it in one leg, the scope, because the agent already did the authenticate leg itself. ## Run it @@ -65,29 +63,29 @@ cargo run -p cpex-tutorial --example m13_client ``` ``` -▸ agent (cpex-agent client) → search_repos (subject: client — scopes the agent's own token) +▸ agent (cpex-agent client) → search_repos (subject: client, scopes the agent's own token) ✓ ALLOWED { ... "repositories":[ ... ] } -▸ anonymous → search_repos (subject: client — no client token to exchange, delegation fails) +▸ anonymous → search_repos (subject: client, no client token to exchange, delegation fails) ✗ DENIED [delegation.bad_request] ... empty bearer_token ... ``` -The agent's own token was narrowed to the `workday-api` audience and the call went through. The anonymous request had no client token to exchange, so — exactly like `subject: user` — delegation failed. +The agent's own token was narrowed to the `github-api` audience and the call went through. The anonymous request had no client token to exchange, so delegation failed, exactly as it would under `subject: user`. ## Try it -1. **Swap to `this_workload`.** Change `subject: client` to `subject: this_workload` and re-run the anonymous case. Expect: it now **succeeds** — the gateway mints from its own credentials and needs no caller token. That is the line between "the agent's authority" and "the gateway's authority." -2. **Give the client the wrong audience.** In `realm-export.json`, remove the `audience-cpex-gateway` mapper from the `cpex-agent` client and restart the IdP. Expect: the exchange fails — Keycloak rejects a subject token whose audience doesn't include the exchanging client (`cpex-gateway`). -3. **Point a user token at it.** A user token also resolves into the `client` slot under `role: client`, so `subject: client` would scope *it* too — the subject follows the slot, not the human/machine distinction. Match `role:` to what actually arrives. +1. **Swap to `this_workload`.** Change `subject: client` to `subject: this_workload` and re-run the anonymous case. Expect: it now succeeds, because the gateway mints from its own credentials and needs no caller token. +2. **Give the client the wrong audience.** In `realm-export.json`, remove the `audience-cpex-gateway` mapper from the `cpex-agent` client and restart the IdP. Expect: the exchange fails, because Keycloak rejects a subject token whose audience doesn't include the exchanging client (`cpex-gateway`). +3. **Point a user token at it.** A user token also resolves into the `client` slot under `role: client`, so `subject: client` would scope *it* too: the subject follows the slot, not the human/machine distinction. Match `role:` to what actually arrives. ## Checkpoint {{< details "Why does anonymous fail here but succeed under this_workload?" >}} -`subject: client` exchanges the **caller's** client token; an anonymous request has none. `subject: this_workload` uses the **gateway's own** client credentials, so there is no caller token it needs. Both mint a downstream token — the difference is whose authority it carries. +`subject: client` exchanges the caller's client token; an anonymous request has none. `subject: this_workload` uses the gateway's own client credentials, so there is no caller token it needs. Both mint a downstream token; they differ in whose authority it carries. {{< /details >}} {{< details "Is this one leg or two?" >}} -One. The agent did the authenticate leg upstream (it got its client token from the IdP itself), so CPEX performs only the **scope** leg — a plain RFC 8693 exchange. The two-leg path is [Recipe 2]({{< relref "/docs/identity-delegation#recipe-2-agent-acting-as-itself-by-its-spiffe-svid" >}}), where the agent presents a SPIFFE SVID and CPEX does both legs. Match the subject to what arrived: a JWT minted from an SVID is a `client`; the SVID itself is a `caller_workload`. +One. The agent did the authenticate leg upstream (it got its client token from the IdP itself), so CPEX performs only the scope leg, a plain RFC 8693 exchange. The two-leg path is [Recipe 2]({{< relref "/docs/identity-delegation#recipe-2-agent-acting-as-itself-by-its-spiffe-svid" >}}), where the agent presents a SPIFFE SVID and CPEX does both legs. Match the subject to what arrived: a JWT minted from an SVID is a `client`; the SVID itself is a `caller_workload`. {{< /details >}} ## Go deeper @@ -97,4 +95,4 @@ One. The agent did the authenticate leg upstream (it got its client token from t ## Next -[Module 14: Passthrough]({{< relref "14-passthrough" >}}): the opposite move — when to forward the caller's token unchanged instead of minting one. +[Module 14: Passthrough]({{< relref "14-passthrough" >}}): the opposite move, forwarding the caller's token unchanged instead of minting one. diff --git a/docs/content/docs/tutorial/14-passthrough.md b/docs/content/docs/tutorial/14-passthrough.md index ae54eb1e..ce163aae 100644 --- a/docs/content/docs/tutorial/14-passthrough.md +++ b/docs/content/docs/tutorial/14-passthrough.md @@ -9,17 +9,17 @@ weight: 15 > > **Cookbook recipe:** [Recipe 4: Forward a token the caller already has]({{< relref "/docs/identity-delegation#recipe-4-forward-a-token-the-caller-already-has-passthrough" >}}). -**Goal:** recognize the case where the right move is to mint *nothing* — validate the caller's token and forward it unchanged. +**Goal:** recognize the case where the right move is to mint *nothing*, and instead validate the caller's token and forward it unchanged. ## The problem -Modules 6, 12 and 13 all minted a downstream token with an RFC 8693 exchange. But minting is not always the answer. Sometimes the caller already holds a token that is correct for the downstream — the *agent-brokered* case: the agent authenticated to the IdP itself and handed CPEX a ready token. Exchanging it would be pure overhead. +Sometimes the caller already holds a token that is correct for the downstream. In the *agent-brokered* case the agent authenticated to the IdP itself and handed CPEX a ready token, and the exchange modules 6, 12 and 13 perform would be pure overhead. -**Passthrough** is that case, and it is defined by what is *absent*: there is no `delegate(...)` step. CPEX validates the token inbound, authorizes the call, and the token the caller presented is the token that flows on. +Passthrough is that case, defined by what is *absent*: there is no `delegate(...)` step. CPEX validates the token inbound, authorizes the call, and the token the caller presented is the token that flows on. ## Build it -There is nothing to add — there is something to *leave out*. A plain resolver (as in module 2), a route that authorizes, and **no delegator plugin at all**. From [`policies/m14.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m14.yaml): +There is nothing to add, only something to *leave out*: a plain resolver (as in module 2), a route that authorizes, and no delegator plugin at all. From [`policies/m14.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m14.yaml): ```yaml plugins: @@ -33,14 +33,12 @@ routes: authentication: [keycloak] authorization: pre_invocation: - - "require(authenticated)" # no delegate step — the caller's token is forwarded + - "require(authenticated)" # no delegate step, so the caller's token is forwarded ``` -Contrast the two shapes directly: - | Shape | Step | What flows downstream | |---|---|---| -| **Passthrough** (this module) | *no* `delegate` | the caller's own token, unchanged | +| Passthrough (this module) | *no* `delegate` | the caller's own token, unchanged | | Mint (modules 6, 13) | `delegate(subject: …)` | a fresh token, scoped to one audience | ## Run it @@ -50,30 +48,26 @@ cargo run -p cpex-tutorial --example m14_passthrough ``` ``` -▸ alice → search_repos (validated and forwarded — no token minted) +▸ alice → search_repos (validated and forwarded, no token minted) ✓ ALLOWED { ... "repositories":[ ... ] } ▸ anonymous → search_repos (no token to forward, require(authenticated) denies) ✗ DENIED [...] access denied ``` -`alice`'s token was validated and the call went through with no exchange — her token is what would reach the backend. The anonymous caller had no token to forward, so `require(authenticated)` denied it. - -## The trade-off - -Passthrough is the cheapest option and the least contained. A forwarded token carries **whatever scope it was issued with** — as broad as the caller's original. A minted token (modules 6/13) is narrowed to one audience, so a leak downstream is bounded. +`alice`'s token was validated and the call went through with no exchange, so her token is what would reach the backend. The anonymous caller had no token to forward, so `require(authenticated)` denied it. -The rule of thumb: **forward only when the caller's token is already scoped for the downstream.** If it is broader than the call needs, mint instead. The choice is a security judgment, and CPEX makes it a one-line difference in policy — a `delegate` step, or none. +Passthrough is the cheapest option and the least contained: a forwarded token is as broad as it was issued, where a minted one is narrowed to a single audience. Forward only when the caller's token is already scoped for the downstream. It is a security judgment, and in policy it is one line of difference: a `delegate` step, or none. ## Try it -1. **Add a mint step.** Drop a `delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)` before `require(authenticated)` (and the delegator plugin from module 13). Now the route *mints* instead of forwarding. Same allow result — a different, narrower token downstream. -2. **Expire the trust.** Point `audiences:` at something the token doesn't carry (e.g. `[workday-api]`) and re-run alice. Expect: inbound validation fails — passthrough still requires a *valid* token, it just doesn't exchange it. +1. **Add a mint step.** Drop a `delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)` before `require(authenticated)` (and the delegator plugin from module 13). Now the route *mints* instead of forwarding: the same allow result, a narrower token downstream. +2. **Break the audience.** Point `audiences:` at something the token doesn't carry (e.g. `[workday-api]`) and re-run alice. Expect: inbound validation fails, because passthrough still requires a *valid* token, it just doesn't exchange it. ## Checkpoint {{< details "How is this different from module 2?" >}} -Module 2 resolved identity to make an *authorization* decision. Passthrough is about the *outbound* token: having validated the caller, the route forwards that same token rather than minting a new one. The novelty is what leaves the boundary, not how the caller is identified. +Module 2 resolved identity to make an *authorization* decision. Passthrough is about the *outbound* token: having validated the caller, the route forwards that same token rather than minting a new one. What changes is what leaves the boundary, not how the caller is identified. {{< /details >}} {{< details "When is passthrough the wrong choice?" >}} diff --git a/docs/content/docs/tutorial/15-dual-principal.md b/docs/content/docs/tutorial/15-dual-principal.md index 0d2d6b76..8805bd97 100644 --- a/docs/content/docs/tutorial/15-dual-principal.md +++ b/docs/content/docs/tutorial/15-dual-principal.md @@ -9,20 +9,20 @@ weight: 16 > > **Cookbook recipe:** [Recipe 6: User acting through an agent, with the agent named]({{< relref "/docs/identity-delegation#recipe-6-user-acting-through-an-agent-with-the-agent-named-dual-principal" >}}). -**Goal:** mint a token that speaks *for* the user and also *names the agent* that carried out the call — two principals on one exchange. +**Goal:** mint a token that speaks *for* the user and also *names the agent* that carried out the call: two principals on one exchange. ## The problem -Every delegation so far spoke for a single principal. But the common agentic shape has two parties: a **human decides**, and an **agent acts**. You want the audit trail — and the minted token — to record both: the user as the authority (`sub`), and the agent as the acting party (`act`). RFC 8693 calls this **delegation**; recording only the subject is **impersonation**. +The common agentic shape has two parties: a human decides, and an agent acts. Every delegation so far spoke for one principal, but here you want the audit trail, and the minted token, to record both: the user as the authority (`sub`), and the agent as the acting party (`act`). RFC 8693 calls this *delegation*; recording only the subject is *impersonation*. Two ideas, kept separate: -- **`subject:`** — who the token speaks **for** (whose authority). Least-privilege scoping follows the subject. -- **`actor:`** — who is **doing** it (attribution). The `act` claim records the agent; it grants nothing. +- **`subject:`** is who the token speaks for, whose authority it carries. Least-privilege scoping follows the subject. +- **`actor:`** is who is doing it. The `act` claim records the agent; it grants nothing. ## Build it -Both credentials arrive on **every** call, on different headers, and a resolver picks each up. From [`policies/m15.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m15.yaml): +Both credentials arrive on every call, on different headers, and a resolver picks each up. From [`policies/m15.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m15.yaml): ```yaml plugins: @@ -44,7 +44,7 @@ routes: - "require(delegation.granted)" ``` -`subject: user` makes the user's token the RFC 8693 `subject_token`; `actor: client` *additionally* attaches the agent's token as the `actor_token`. **One exchange, two principals in the request** — not a second leg. +`subject: user` makes the user's token the RFC 8693 `subject_token`; `actor: client` *additionally* attaches the agent's token as the `actor_token`. That is one exchange carrying two principals, not a second leg. ## Run it @@ -63,36 +63,34 @@ cargo run -p cpex-tutorial --example m15_dual_principal ✗ DENIED [...] `actor:` is not supported with `subject: caller_workload` or `subject: this_workload` ... ``` -The first call carried both principals and went through. The second was missing the agent credential — a dual-principal route needs *both*. The third is the guardrail below. +The first call carried both principals and went through. The second was missing the agent credential, and a dual-principal route needs both. -## Interop: `act` is the token service's call - -Here is the honest part. **CPEX always puts the actor on the wire** (`actor_token` + `actor_token_type`), exactly as RFC 8693 delegation prescribes. Whether `act` lands in the minted token is up to the token service: +The third scenario is a guardrail. `actor:` only pairs with an on-behalf-of subject (`user` or `client`). With `subject: this_workload` (a `client_credentials` grant that carries no `actor_token`) or `subject: caller_workload` (already the acting workload), an actor is meaningless, so CPEX denies the step when it runs rather than silently dropping it. -- A **delegation-capable** service records the actor in a nested `act` claim. -- An **impersonation-only** service returns a subject-only token and ignores the actor. +## Interop: `act` is the token service's call -**Keycloak's Standard Token Exchange is impersonation-only** — it silently ignores `actor_token` and emits no `act` (verified here on 26.x). So the first scenario *succeeds*, but the minted token names only alice. To see `act` end to end you need a delegation-capable token service; against Keycloak, capture the acting agent at the CPEX boundary instead (audit, or a downstream header) — CPEX resolved both principals either way. +CPEX always puts the actor on the wire (`actor_token` plus `actor_token_type`), as RFC 8693 delegation prescribes. Whether `act` lands in the minted token is up to the token service: -This is why the module asserts the exchange *succeeds* rather than inspecting the token for `act`: the CPEX half is correct regardless, and the claim's presence isn't CPEX's to guarantee. +- A delegation-capable service records the actor in a nested `act` claim. +- An impersonation-only service returns a subject-only token and ignores the actor. -## The guardrail +Keycloak's Standard Token Exchange is impersonation-only: it ignores `actor_token` and emits no `act` (verified here on 26.x). So the first scenario succeeds, but the minted token names only alice. To see `act` end to end you need a delegation-capable token service. Against Keycloak, capture the acting agent at the CPEX boundary instead, in audit or a downstream header, since CPEX resolved both principals either way. -`actor:` only pairs with an **on-behalf-of** subject (`user` or `client`). With `subject: this_workload` (a `client_credentials` grant that carries no `actor_token`) or `subject: caller_workload` (already the acting workload), an actor is meaningless — so CPEX **denies the step** rather than silently dropping it. The `sync_index` route shows the exact error. +That is why the module asserts the exchange *succeeds* rather than inspecting the token for `act`: the CPEX half is correct regardless, and the claim's presence isn't CPEX's to guarantee. ## Try it -1. **Actor as `caller_workload`.** That variant names the agent by its SPIFFE SVID instead of an OAuth client — same `subject: user`, but `actor: caller_workload`. It needs a SPIFFE issuer (SPIRE); see [module 16]({{< relref "16-workload" >}}). -2. **Authorize on the actor by mistake.** Change `require(role.hr)` to gate on a client attribute. Authorization follows the *subject*; the actor is attribution, not authority — a good way to feel the difference. +1. **Actor as `caller_workload`.** That variant names the agent by its SPIFFE SVID instead of an OAuth client: same `subject: user`, but `actor: caller_workload`. It needs a SPIFFE issuer (SPIRE); see [module 16]({{< relref "16-workload" >}}). +2. **Authorize on the actor by mistake.** Change `require(role.hr)` to gate on a client attribute. Authorization follows the *subject*; the actor is attribution, not authority. ## Checkpoint {{< details "Why does the ALLOW scenario not prove `act` is in the token?" >}} -Because with Keycloak it isn't — its exchange is impersonation-only and drops the actor. CPEX still sends it correctly; emitting `act` is the token service's job. The test asserts what CPEX controls (the exchange succeeds, both principals resolved), not what the IdP controls. +Because with Keycloak it isn't: its exchange is impersonation-only and drops the actor. CPEX still sends it correctly, and emitting `act` is the token service's job. The test asserts what CPEX controls (the exchange succeeds, both principals resolved), not what the IdP controls. {{< /details >}} -{{< details "subject vs. actor — which one is scoped?" >}} -The subject. Least-privilege scoping follows whose authority the token carries. `actor:` only adds attribution — it records who acted, and grants nothing. +{{< details "subject vs. actor: which one is scoped?" >}} +The subject. Least-privilege scoping follows whose authority the token carries. `actor:` only adds attribution: it records who acted, and grants nothing. {{< /details >}} ## Go deeper diff --git a/docs/content/docs/tutorial/16-workload.md b/docs/content/docs/tutorial/16-workload.md index 89ca1e8a..a16f51f7 100644 --- a/docs/content/docs/tutorial/16-workload.md +++ b/docs/content/docs/tutorial/16-workload.md @@ -5,26 +5,26 @@ weight: 17 # Module 16: Workload identity (the agent proves itself with a SPIFFE SVID) -> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP **and** the SPIRE overlay. +> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP and the SPIRE overlay. > > **Cookbook recipe:** [Recipe 2: Agent acting as itself, by its SPIFFE SVID]({{< relref "/docs/identity-delegation#recipe-2-agent-acting-as-itself-by-its-spiffe-svid" >}}). -**Goal:** let a workload authenticate with a **SPIFFE SVID** — no user, no client secret — and have CPEX broker a scoped downstream token from it. +**Goal:** let a workload authenticate with a SPIFFE SVID, no user and no client secret, and have CPEX broker a scoped downstream token from it. ## The problem -Every caller so far held an OAuth credential: a user's JWT (module 6), a client's token (module 13), or nothing (passthrough). But a workload's native identity is a **SPIFFE SVID** — an ES256 JWT signed by **SPIRE**, not your IdP. It proves *what the workload is* (`sub` = `spiffe://…/agent/hr-copilot`), attested by the platform, with no secret to leak. +A workload's native identity is a SPIFFE SVID: an ES256 JWT signed by SPIRE, not by your IdP. It proves *what the workload is* (`sub` = `spiffe://…/agent/hr-copilot`), attested by the platform, with no secret to leak. Every caller so far held an OAuth credential instead, whether a user's JWT (module 6), a client's token (module 13), or nothing (passthrough). -An SVID is an identity credential, **not an OAuth token**: it can't be forwarded downstream or used as an exchange subject as-is. So `subject: caller_workload` runs **two legs**: +An SVID is an identity credential, not an OAuth token: it can't be forwarded downstream or used as an exchange subject as-is. So `subject: caller_workload` runs two legs: -1. **Authenticate** — present the SVID as an RFC 7523 `client_assertion` (type `…:jwt-spiffe`) so the IdP issues an ordinary token for the agent. -2. **Scope** — exchange that token (RFC 8693) for the downstream-scoped token. +1. **Authenticate.** Present the SVID as an RFC 7523 `client_assertion` (type `…:jwt-spiffe`) so the IdP issues an ordinary token for the agent. +2. **Scope.** Exchange that token (RFC 8693) for the downstream-scoped token. -The agent holds no standing entitlement to the target — CPEX brokers the scope-up with its **own** gateway credential (leg 2). A compromised agent can prove *who it is* but cannot mint the downstream token itself. That is what makes CPEX the trust boundary. +The agent holds no standing entitlement to the target: CPEX brokers the scope-up with its own gateway credential in leg 2. A compromised agent can prove who it is but cannot mint the downstream token itself. ## Bring up the infrastructure -This module needs a SPIFFE authority (SPIRE) and a Keycloak that speaks SPIFFE. Both come from the **opt-in overlay** — modules 0–15 don't use it. +This module needs a SPIFFE authority (SPIRE) and a Keycloak that speaks SPIFFE. Both come from an opt-in overlay that modules 0 to 15 never use. ```bash # SPIRE server + OIDC provider, and Keycloak bumped to 26.6.1 with spiffe:v1 @@ -35,7 +35,9 @@ docker compose -f examples/tutorial/idp/docker-compose.yml \ ./examples/tutorial/idp/spire/setup-spiffe.sh ``` -The setup script does two things via the admin API (kept out of the realm export so the base Keycloak never sees SPIFFE config): registers a **SPIFFE identity provider** that validates SVIDs against SPIRE's JWKS, and creates a **`federated-jwt` client** (`hr-copilot-agent`) bound to `spiffe://cpex.tutorial/agent/hr-copilot`, with an audience mapper so the gateway can exchange its token. +The setup script does two things through the admin API, kept out of the realm export so the base Keycloak never sees SPIFFE config. It registers a SPIFFE identity provider that validates SVIDs against SPIRE's JWKS, and it creates a `federated-jwt` client (`hr-copilot-agent`) bound to `spiffe://cpex.tutorial/agent/hr-copilot`, with an audience mapper so the gateway can exchange its token. + +`make tutorial-check-spire` runs the same sequence and then the module in `--check` mode. ## Build it @@ -63,7 +65,7 @@ routes: - "require(delegation.granted)" ``` -The delegator is the same `cpex-gateway` OAuth delegator as every other module — `subject: caller_workload` is what tells it to run the two legs. +The delegator is the same `cpex-gateway` OAuth delegator as every other module. `subject: caller_workload` is what tells it to run the two legs. ## Run it @@ -75,7 +77,7 @@ cargo run -p cpex-tutorial --example m16_workload ▸ agent (SVID on X-Workload-Token) → search_repos (subject: caller_workload, two-leg) ✓ ALLOWED { ... "repositories":[ ... ] } -▸ anonymous → search_repos (no SVID, the workload route can't authenticate) +▸ anonymous → search_repos (no SVID, so nothing to broker from) ✗ DENIED [delegation.bad_request] ... empty bearer_token ... ``` @@ -83,20 +85,20 @@ The example mints the SVID off SPIRE (`spire-server jwt mint`), presents it on ` ## The SVID vs. a token minted from one -This is the distinction to hold onto (it's the line between this module and module 13): +How this differs from module 13: | The agent presents | Slot → subject | CPEX does | Legs | |---|---|---|---| -| its **SVID** (ES256, SPIRE's JWKS) | `caller_workload` → `subject: caller_workload` | authenticate **+** scope | 2 (this module) | -| a **token minted from its SVID** (RS256, IdP's JWKS) | `client` → `subject: client` | scope only | 1 ([module 13]({{< relref "13-client" >}})) | +| its SVID (ES256, SPIRE's JWKS) | `caller_workload` → `subject: caller_workload` | authenticate, then scope | 2 (this module) | +| a token minted from its SVID (RS256, IdP's JWKS) | `client` → `subject: client` | scope only | 1 ([module 13]({{< relref "13-client" >}})) | Match the subject to what actually arrived. Using `subject: caller_workload` on an already-minted token would misroute it down the two-leg `client_assertion` path. ## Try it -1. **Name the agent as the actor.** Combine with [module 15]({{< relref "15-dual-principal" >}}): add a user on `X-User-Token`, keep the SVID, and use `subject: user, actor: caller_workload` — the human authorizes, the agent (by SVID) is the actor. -2. **Tamper with the SVID.** Change one character of the token before presenting it. Expect: the resolver rejects it — the ES256 signature no longer verifies against SPIRE's JWKS. -3. **Wrong audience.** Mint the SVID with `-audience something-else`. Expect: leg 1 fails — the SPIFFE client-auth draft requires the SVID's audience to be the IdP it authenticates to. +1. **Name the agent as the actor.** Combine with [module 15]({{< relref "15-dual-principal" >}}): add a user on `X-User-Token`, keep the SVID, and use `subject: user, actor: caller_workload`. The human authorizes, the agent is the actor. +2. **Tamper with the SVID.** Change one character of the token before presenting it. Expect: the resolver rejects it, because the ES256 signature no longer verifies against SPIRE's JWKS. +3. **Wrong audience.** Mint the SVID with `-audience something-else`. Expect: leg 1 fails, because the SPIFFE client-auth draft requires the SVID's audience to be the IdP it authenticates to. ## Checkpoint @@ -105,7 +107,7 @@ Because an SVID is not an IdP token. Module 13's caller already held an IdP-issu {{< /details >}} {{< details "Where does the downstream authority come from?" >}} -Leg 2 — CPEX's own gateway credential — not the agent. The SVID proves identity; it grants nothing downstream. That separation is why a compromised agent can't mint the target token itself. +From leg 2, CPEX's own gateway credential, not from the agent. The SVID proves identity and grants nothing downstream, which is why a compromised agent can't mint the target token itself. {{< /details >}} ## Go deeper diff --git a/docs/content/docs/tutorial/17-federation.md b/docs/content/docs/tutorial/17-federation.md index abfcade5..aa3a12b8 100644 --- a/docs/content/docs/tutorial/17-federation.md +++ b/docs/content/docs/tutorial/17-federation.md @@ -7,13 +7,13 @@ weight: 18 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. -**Goal:** accept callers from **more than one identity provider** with a single resolver, each token validated against its own issuer's keys. +**Goal:** accept callers from more than one identity provider with a single resolver, each token validated against its own issuer's keys. ## The problem -Every module so far trusted exactly one issuer. Real enforcement points rarely have that luxury: your own workforce IdP *and* a partner org's, a legacy realm *and* its replacement during a migration, one IdP per business unit. You want to accept tokens from all of them — but only those — and validate each on the correct keys. +A single enforcement point often fronts several identity providers: your own workforce IdP and a partner org's, a legacy realm and its replacement during a migration, one IdP per business unit. You want to accept tokens from all of them, only those, and validate each on the correct keys. -A JWT names its issuer in the `iss` claim. CPEX matches that to a **trusted issuer** and validates the token against *that* issuer's JWKS. List several, and one resolver federates them; a token whose `iss` is in none is rejected with `auth.untrusted_issuer`. +A JWT names its issuer in the `iss` claim. CPEX matches that to a trusted issuer and validates the token against *that* issuer's JWKS. List several, and one resolver federates them; a token whose `iss` is in none is rejected with `auth.untrusted_issuer`. ## Build it @@ -37,7 +37,7 @@ plugins: decoding_key: { kind: jwks_url, url: "…/cpex-partner/…/certs" } # ITS OWN keys ``` -Each entry is a full trust anchor: its own issuer string, its own JWKS, its own accepted audiences. Here both issuers are realms in the same Keycloak, but they could be entirely separate products — the resolver doesn't care, it just matches `iss`. +Each entry is a full trust anchor: its own issuer string, its own JWKS, its own accepted audiences. Here both issuers are realms in the same Keycloak, but they could be entirely separate products. The resolver only matches `iss`. ## Run it @@ -54,21 +54,21 @@ cargo run -p cpex-tutorial --example m17_federation ▸ pat (partner realm cpex-partner) → get_compensation (issuer #2, validated on ITS keys) ✓ ALLOWED { ... } -▸ outsider (master realm — an untrusted issuer) → get_compensation (rejected) +▸ outsider (master realm, an untrusted issuer) → get_compensation (rejected) ✗ DENIED [auth.untrusted_issuer] issuer 'http://localhost:8081/realms/master' is not in the trusted-issuer list ``` -Both `alice` and `pat` reach the same route under the same rule (`require(role.hr)`) — the policy never mentions issuers. The third token is a genuine, validly-signed JWT, but from Keycloak's `master` realm, which the resolver doesn't trust — so it's rejected before any authorization runs. +Both `alice` and `pat` reach the same route under the same rule (`require(role.hr)`), and the policy never mentions issuers. The third token is a genuine, validly-signed JWT from Keycloak's `master` realm, which the resolver doesn't trust, so it is rejected before any authorization runs. ## Claims still have to line up -Federation validates *signatures*; it does not normalize *claims*. Two IdPs may express roles differently, and the resolver reads a flat `roles` array (module 2). The partner realm here is configured to emit the same `roles` / `permissions` shape as the home realm, which is why `pat` satisfies `require(role.hr)`. When you federate a *real* partner IdP, mapping its claims into the shape your policy expects is the actual work — the trust list is the easy half. +Federation validates *signatures*; it does not normalize *claims*. Two IdPs may express roles differently, and the resolver reads a flat `roles` array (module 2). The partner realm here is configured to emit the same `roles` and `permissions` shape as the home realm, which is why `pat` satisfies `require(role.hr)`. Against a real partner IdP, mapping its claims into the shape your policy expects is the harder half; the trust list is the easy one. ## Try it -1. **Drop the partner issuer.** Delete the second `trusted_issuers` entry and re-run. Expect: `pat` now fails with `auth.untrusted_issuer` — same token, no longer trusted. -2. **Break the partner keys.** Point the partner entry's `decoding_key.url` at the *home* realm's certs. Expect: `pat` fails signature validation — each issuer must be verified on its own keys. -3. **Diverge the claims.** Give `pat` a role the home realm doesn't use and gate the route on it. That's the per-issuer claim-mapping problem in miniature. +1. **Drop the partner issuer.** Delete the second `trusted_issuers` entry and re-run. Expect: `pat` now fails with `auth.untrusted_issuer`, the same token, no longer trusted. +2. **Break the partner keys.** Point the partner entry's `decoding_key.url` at the *home* realm's certs. Expect: `pat` fails signature validation, because each issuer must be verified on its own keys. +3. **Diverge the claims.** Give `pat` a role the home realm doesn't use and gate the route on it. That is the per-issuer claim-mapping problem, in one realm. ## Checkpoint @@ -83,7 +83,7 @@ No. Trust means "I accept tokens this issuer signed." What those tokens are *all ## Go deeper - [Identity → Multiple sources]({{< relref "/docs/apl/identity#multiple-sources" >}}) for the full multi-issuer / multi-resolver model. -- [Configuration]({{< relref "/docs/configuration" >}}) for the `trusted_issuers` schema. +- [Configuration]({{< relref "/docs/configuration" >}}#plugins) for the `trusted_issuers` schema. ## Next diff --git a/docs/content/docs/tutorial/18-attributes.md b/docs/content/docs/tutorial/18-attributes.md index 26d01d77..c0a80a19 100644 --- a/docs/content/docs/tutorial/18-attributes.md +++ b/docs/content/docs/tutorial/18-attributes.md @@ -7,13 +7,13 @@ weight: 19 > You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP. -**Goal:** feed policy facts that no token carries — operator decisions maintained in a data file — and read them per request as `data.*`. +**Goal:** feed policy the facts no token carries, operator decisions maintained in a data file, and read them per request as `data.*`. ## The problem -Most attributes a predicate reads come from the request: the subject and its roles (module 2), session labels (module 7), headers. But some facts are carried by nothing. Which region a deployment's data must stay in. Which tools are switched on this week. The org's default tier. These are **operator decisions**, known at configuration time and belonging in neither the token nor the application code. +Some facts are carried by nothing. Which region a deployment's data must stay in. Which tools are switched on this week. The org's default tier. These are operator decisions, known at configuration time and belonging in neither the token nor the application code, unlike the attributes of modules 2 and 7, which arrive with the request. -CPEX provisions them from a plain data file into the `data.*` namespace. Identity turns a token into `subject.*`; static provisioning turns a config file into `data.*` — and predicates read both the same way. +CPEX provisions them from a plain data file into the `data.*` namespace. Identity turns a token into `subject.*`; static provisioning turns a config file into `data.*`, and predicates read both the same way. ## Build it @@ -48,10 +48,14 @@ routes: - "deny('get_compensation is disabled by operations', 'ops.tool_disabled')" ``` -The whole file flattens into the bag: `data.org.data_region`, `data.controls.tools.get_compensation.enabled`, and so on. Multiple files deep-merge in order, and the merge is **fail-fast** — two files setting the same leaf differently is a load error, and a file missing its `data:` wrapper is rejected. A config mistake stops startup rather than quietly mis-routing. +The whole file flattens into the bag: `data.org.data_region`, `data.controls.tools.get_compensation.enabled`, and so on. Multiple files deep-merge in order, and the merge is fail-fast: two files setting the same leaf differently is a load error, and a file missing its `data:` wrapper is rejected. A config mistake stops startup rather than quietly mis-routing. + +The tree holds literal values only, with no conditionals, computed fields, or cross-references. A data document has no syntax for logic, so the static layer cannot grow into a second, shadow policy engine. It provisions the facts; APL decides with them. ## Run it +Run this one from the repo root: `attribute_files` paths resolve against the working directory. + ```bash cargo run -p cpex-tutorial --example m18_attributes ``` @@ -64,43 +68,39 @@ cargo run -p cpex-tutorial --example m18_attributes ✓ ALLOWED { ... } ``` -The same caller reached one tool and was refused another. Nothing about alice changed between the two calls — the outcome came from a fact in a file, read as `data.*`. +The same caller reached one tool and was refused another. Nothing about alice changed between the two calls; the outcome came from a fact in a file, read as `data.*`. ## Reading the tree per caller -The powerful form indexes the tree by a *request* value, so one rule serves every caller: +This module reads fixed paths, but a path can also be indexed by a *request* value, so one rule serves every caller: ```yaml - when: "data.tenants[subject.tenant].data_region == 'eu'" do: [ "taint(eu_resident, session)" ] ``` -`[subject.tenant]` is substituted at evaluation time — the predicate reads `data.tenants..data_region`. If the indexed value is missing, the whole path resolves to absent and the predicate is simply false (a `require` on it fails closed), so an unknown caller is never silently unconstrained. The set-valued fields of the [`restrict`]({{< relref "/docs/apl/restrict" >}}) effect can take a `data.*` reference the same way — one routing rule, per-caller allow-lists from the tree. - -## Data, not a rules engine - -The tree holds **literal values only** — no conditionals, no computed fields, no cross-references. Any "if X then Y" is policy's job; put it in a route. A plain data document has no syntax to express logic, so the static layer can't quietly grow into a second, shadow policy engine. It provisions the facts; APL decides with them. +`[subject.tenant]` is substituted at evaluation time, so the predicate reads `data.tenants..data_region`. A missing value resolves to absent and the predicate is false, so a `require` on it fails closed. [Static Attributes]({{< relref "/docs/apl/attributes" >}}) has the runnable version, along with the `restrict` fields that take a `data.*` reference the same way. ## Try it -1. **Flip a switch.** Set `search_repos.enabled: false` in `controls.yaml` and re-run. Expect: now *both* tools are refused — you changed behavior by editing a data file, touching no policy and no code. -2. **Break the merge.** Add a second file to `attribute_files` that sets `data.org.data_region` to a *different* value. Expect: a load-time error — the merge is fail-fast, not last-wins. -3. **Forget the wrapper.** Remove the top-level `data:` key from the file. Expect: rejected at load — the file must declare `data:`. +1. **Flip a switch.** Set `search_repos.enabled: false` in `controls.yaml` and re-run. Expect: now *both* tools are refused, and you changed behavior by editing a data file, touching no policy and no code. +2. **Break the merge.** Add a second file to `attribute_files` that sets `data.org.data_region` to a *different* value. Expect: a load-time error, because the merge is fail-fast, not last-wins. +3. **Forget the wrapper.** Remove the top-level `data:` key from the file. Expect: rejected at load, because the file must declare `data:`. ## Checkpoint {{< details "How is data.* different from subject.*?" >}} -`subject.*` is dynamic — resolved from the token this request carried. `data.*` is static — provisioned from a file at startup and shared across requests. One is what the caller *is*; the other is what the operator has *decided*. Predicates read both identically and combine them freely. +`subject.*` is dynamic, resolved from the token this request carried. `data.*` is static, provisioned from a file at startup and shared across requests. One is what the caller *is*; the other is what the operator has *decided*. Predicates read both identically and combine them freely. {{< /details >}} {{< details "Why not just put the flag in the route?" >}} -You could — but then flipping a tool off is a policy edit and review. Facts an operator changes often (kill switches, region maps, per-tenant tiers) live better as data an operator maintains, keyed by the request, so one rule serves everyone and the change is a data edit, not a policy one. +You could, but then flipping a tool off is a policy edit and review. Facts an operator changes often (kill switches, region maps, per-tenant tiers) live better as data an operator maintains, keyed by the request, so one rule serves everyone and the change is a data edit, not a policy one. {{< /details >}} ## Go deeper - [Static Attributes]({{< relref "/docs/apl/attributes" >}}) for the full `data.*` model, merge rules, and the `AttributeSource` trait (etcd / DB / ConfigMap sources). -- [Backend Restriction]({{< relref "/docs/apl/restrict" >}}) — where `data.*` references feed per-caller routing constraints. +- [Backend Restriction]({{< relref "/docs/apl/restrict" >}}) for where `data.*` references feed per-caller routing constraints. ## Next diff --git a/docs/content/docs/tutorial/_index.md b/docs/content/docs/tutorial/_index.md index 755c85ab..50417e68 100644 --- a/docs/content/docs/tutorial/_index.md +++ b/docs/content/docs/tutorial/_index.md @@ -19,10 +19,10 @@ One idea repeats in every module: the application never changes. Only the policy ## Before you start - Rust 1.96 or newer, and Cargo. -- A container runtime with compose (Rancher Desktop, Podman, or Docker Desktop). You need it from module 2 on, where a real Keycloak resolves tokens. Modules 0 and 1 need only Rust. +- A container runtime with compose (Rancher Desktop, Podman, or Docker Desktop). You need it from module 2 on, where a real Keycloak resolves tokens. Modules 0 and 1 need only Rust, and module 16 additionally needs the SPIRE overlay it describes. - The code lives in [`examples/tutorial`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial). Run a module with `cargo run -p cpex-tutorial --example m01_hello`. -Budget about 3 to 4 hours total, 15 to 25 minutes per module. +Budget 15 to 25 minutes per module: about 3 to 4 hours for modules 0 to 10 and the capstone, which are the core path. Modules 11 to 18 are deep dives on organizing policy and on identity and delegation; take them in any order, or skip to the capstone and come back. ## Modules @@ -43,8 +43,8 @@ Budget about 3 to 4 hours total, 15 to 25 minutes per module. | 12 | [Delegation subjects]({{< relref "12-subjects" >}}) | Mint a downstream token as the caller (`user`) or as the gateway itself (`this_workload`) | yes | | 13 | [Delegation as a client]({{< relref "13-client" >}}) | Scope an agent's own client token with `subject: client` (cookbook Recipe 5) | yes | | 14 | [Passthrough (forward, don't mint)]({{< relref "14-passthrough" >}}) | When to forward the caller's token unchanged instead of exchanging it (cookbook Recipe 4) | yes | -| 15 | [Dual-principal (subject + actor)]({{< relref "15-dual-principal" >}}) | Mint on behalf of a user while naming the acting agent — `subject: user, actor: client` (cookbook Recipe 6) | yes | -| 16 | [Workload identity (SVID)]({{< relref "16-workload" >}}) | An agent authenticates by SPIFFE SVID; CPEX brokers a scoped token in two legs — `subject: caller_workload` (cookbook Recipe 2) | yes + SPIRE | +| 15 | [Dual-principal (subject + actor)]({{< relref "15-dual-principal" >}}) | Mint on behalf of a user while naming the acting agent, `subject: user, actor: client` (cookbook Recipe 6) | yes | +| 16 | [Workload identity (SVID)]({{< relref "16-workload" >}}) | An agent authenticates by SPIFFE SVID; CPEX brokers a scoped token in two legs, `subject: caller_workload` (cookbook Recipe 2) | yes + SPIRE | | 17 | [Multi-issuer (trust federation)]({{< relref "17-federation" >}}) | Accept callers from more than one IdP with one resolver, each validated on its own keys | yes | | 18 | [Static attributes (data.*)]({{< relref "18-attributes" >}}) | Feed policy operator-maintained facts from a data file, read per request as `data.*` | yes | | C | [Capstone: the three-backend agent]({{< relref "capstone" >}}) | Assemble every control into the full Overview scenario | yes | From 8865aa9d8f03a4512564164133f6c6e42ba1e4f9 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 15 Aug 2026 12:51:31 -0400 Subject: [PATCH 7/7] docs(tutorial): document the new realm, agent client and SPIRE overlay The IdP README is the reference for the stack, so give it the cpex-agent client, the cpex-partner realm and its user, and the opt-in SPIFFE overlay. Switch setup-spiffe.sh to jq, which the rest of the tutorial already uses, and say plainly what re-running it does. Signed-off-by: Frederico Araujo --- examples/tutorial/README.md | 4 +-- examples/tutorial/idp/README.md | 33 +++++++++++++++++-- .../tutorial/idp/docker-compose.spire.yml | 12 +++---- examples/tutorial/idp/docker-compose.yml | 2 +- .../tutorial/idp/spire/server/server.conf | 2 +- examples/tutorial/idp/spire/setup-spiffe.sh | 15 +++++---- 6 files changed, 49 insertions(+), 19 deletions(-) diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 95a33ed0..99d23133 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -36,8 +36,8 @@ The realm takes a few seconds to import. Wait for it before running an IdP-backe cargo run -p cpex-tutorial --example wait_for_idp # blocks until the realm is serving ``` -`make tutorial-check` runs this probe automatically before the IdP modules. See [`idp/README.md`](idp/README.md) for the personas and how to mint a token by hand. +`make tutorial-check` runs this probe automatically before the IdP modules. Module 16 additionally needs the SPIRE overlay, which `make tutorial-check-spire` brings up. See [`idp/README.md`](idp/README.md) for the personas and how to mint a token by hand. ## Editing policy -For modules 2–8 you change `policies/*.yaml`, not Rust. Each policy file has a header explaining what to try. The harness reloads the file on each run (`cargo run ...`), so edit, re-run, observe. +From module 2 on you change `policies/*.yaml`, not Rust. Each policy file has a header explaining what to try. The harness reloads the file on each run (`cargo run ...`), so edit, re-run, observe. diff --git a/examples/tutorial/idp/README.md b/examples/tutorial/idp/README.md index 67501c5f..6da6180a 100644 --- a/examples/tutorial/idp/README.md +++ b/examples/tutorial/idp/README.md @@ -1,7 +1,8 @@ # CPEX Tutorial: Keycloak IdP A throwaway [Keycloak](https://www.keycloak.org/) realm that provides users, -roles, and clients for the CPEX tutorial. It mints the JWTs the CPEX gateway +roles, and clients for the CPEX tutorial, plus a second realm that stands in +for a partner organization. It mints the JWTs the CPEX gateway validates, and it backs the token-exchange and CIBA exercises. > **⚠️ Tutorial-only credentials.** The admin login, every client secret, and @@ -36,6 +37,7 @@ All users have `emailVerified: true` and are enabled for the password | `workday-api` | confidential | Token-exchange **target** audience (resource server). | `workday-dev-secret` | | `github-api` | confidential | Token-exchange **target** audience (resource server). | `github-dev-secret` | | `cpex-gateway` | confidential | Token-exchange **requester** + CIBA client. Service account on.| `gateway-dev-secret` | +| `cpex-agent` | confidential | An agent calling in as an OAuth client (tutorial module 13). Service account on, audience `cpex-gateway`. | `agent-dev-secret` | `cpex-gateway` has CIBA (`oidc.ciba.grant.enabled=true`) and standard OAuth2 token exchange (`standard.token.exchange.enabled=true`) enabled. @@ -54,6 +56,31 @@ carries protocol mappers that emit these claims into the **access token**: | `manager` | user attribute `manager`, single-valued | string | | `aud` | audience mapper | includes `cpex-tutorial` | +### Second realm: `cpex-partner` + +`partner-export.json` imports a second realm alongside the first, so the +multi-issuer module (17) has a genuinely different issuer to trust. It carries +one user and one public client, with the same flat-claim mappers as the home +realm: + +| User | Password | Realm role | `permissions` | `team` | +|------|----------|------------|---------------|--------------| +| pat | `pat` | `hr` | `view_ssn` | `partner-hr` | + +| Client | Type | Purpose | +|--------------------|--------|--------------------------------------------------| +| `cpex-partner-app` | public | Mints `pat`'s tokens, aud'd to `cpex-tutorial`. | + +Its issuer is `http://localhost:8081/realms/cpex-partner`, with the JWKS and +token endpoints under the same path. Modules other than 17 ignore it. + +### SPIFFE overlay (module 16 only) + +`docker-compose.spire.yml` adds a SPIRE server and its OIDC discovery provider, +and bumps Keycloak to 26.6.1 with the `spiffe:v1` feature. It is opt-in and +additive; nothing else in the tutorial uses it. Tutorial module 16 has the two +commands, and `make tutorial-check-spire` runs them for you. + --- ## Start it @@ -62,8 +89,8 @@ carries protocol mappers that emit these claims into the **access token**: docker compose up -d ``` -First boot pulls the `quay.io/keycloak/keycloak:26.1` image and imports the -realm, roughly **30 seconds**. Watch readiness with: +First boot pulls the `quay.io/keycloak/keycloak:26.1` image and imports both +realms, roughly **30 seconds**. Watch readiness with: ```bash docker compose ps # STATUS becomes "healthy" once the realm is up diff --git a/examples/tutorial/idp/docker-compose.spire.yml b/examples/tutorial/idp/docker-compose.spire.yml index 095dff64..1f03174e 100644 --- a/examples/tutorial/idp/docker-compose.spire.yml +++ b/examples/tutorial/idp/docker-compose.spire.yml @@ -1,19 +1,19 @@ # ============================================================================= -# CPEX Tutorial — SPIRE overlay for Module 16 (Workload identity / SVID). +# CPEX Tutorial: SPIRE overlay for Module 16 (Workload identity / SVID). # ============================================================================= -# OPT-IN. Modules 0–15 do not need this file — bring it up only for module 16. +# OPT-IN. Modules 0 to 15 do not need this file; bring it up only for module 16. # It is ADDITIVE over idp/docker-compose.yml: # # * bumps Keycloak to 26.6.1 and turns on `spiffe:v1` (the native SPIFFE -# identity provider — lets a client present its JWT-SVID as an RFC 7523 +# identity provider, which lets a client present its JWT-SVID as an RFC 7523 # client_assertion of type ...:jwt-spiffe, which is how an SVID enters # OAuth per draft-ietf-oauth-spiffe-client-auth). Also keeps # `token-exchange-standard` on for leg 2 (the scope-down exchange). # * adds spire-server (the SPIFFE authority) + spire-oidc (its JWKS/OIDC # discovery provider on :8443) so Keycloak can validate the SVID. # -# We mint SVIDs directly off the server (no agent, no attestation) — a genuine -# SPIRE-signed SVID against SPIRE's real JWKS, two containers. +# We mint SVIDs directly off the server (no agent, no attestation): a genuine +# SPIRE-signed SVID against SPIRE's real JWKS, in two containers. # # # Bring up the tutorial IdP + SPIRE: # docker compose -f idp/docker-compose.yml -f idp/docker-compose.spire.yml up -d @@ -48,7 +48,7 @@ services: volumes: - ./spire/server/server.conf:/opt/spire/conf/server/server.conf:ro - spire-server-data:/opt/spire/data/server - # Shared admin socket dir — spire-oidc reads the bundle through it. + # Shared admin socket dir; spire-oidc reads the bundle through it. - spire-server-socket:/tmp/spire-server/private healthcheck: test: ["CMD", "/opt/spire/bin/spire-server", "healthcheck"] diff --git a/examples/tutorial/idp/docker-compose.yml b/examples/tutorial/idp/docker-compose.yml index de5eef81..019cf404 100644 --- a/examples/tutorial/idp/docker-compose.yml +++ b/examples/tutorial/idp/docker-compose.yml @@ -30,7 +30,7 @@ services: volumes: - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro # A second realm = a second issuer, for the multi-issuer module (17). - # --import-realm loads every *.json here; modules 0–16 ignore it. + # --import-realm loads every *.json here; every other module ignores it. # (Named *-export.json, not *-realm.json, so Keycloak's dir-import # doesn't require the file prefix to equal the realm name.) - ./partner-export.json:/opt/keycloak/data/import/partner-export.json:ro diff --git a/examples/tutorial/idp/spire/server/server.conf b/examples/tutorial/idp/spire/server/server.conf index 34226f33..5b9bd196 100644 --- a/examples/tutorial/idp/spire/server/server.conf +++ b/examples/tutorial/idp/spire/server/server.conf @@ -7,7 +7,7 @@ # NEVER use this configuration in production. server { bind_address = "0.0.0.0" - bind_port = "8082" # NOT 8081 — Keycloak owns 8081. + bind_port = "8082" # NOT 8081, which Keycloak owns. trust_domain = "cpex.tutorial" data_dir = "/opt/spire/data/server" log_level = "INFO" diff --git a/examples/tutorial/idp/spire/setup-spiffe.sh b/examples/tutorial/idp/spire/setup-spiffe.sh index 06c2ef02..bf30fce4 100755 --- a/examples/tutorial/idp/spire/setup-spiffe.sh +++ b/examples/tutorial/idp/spire/setup-spiffe.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash # Configure the tutorial Keycloak to trust SPIRE and bind the agent's SPIFFE -# ID — the one-time setup for module 16 (Workload identity / SVID). +# ID: the one-time setup for module 16 (Workload identity / SVID). # # Run it AFTER bringing up the SPIRE overlay: # docker compose -f idp/docker-compose.yml -f idp/docker-compose.spire.yml up -d # ./idp/spire/setup-spiffe.sh # -# It is idempotent — safe to re-run. It applies two things via the admin API -# (kept OUT of realm-export.json so modules 0–15, on the base Keycloak, never -# see SPIFFE config): +# Needs curl and jq. `make tutorial-check-spire` runs it for you. +# +# Safe to re-run: the identity provider is updated in place and the client is +# recreated. It applies two things through the admin API, kept OUT of +# realm-export.json so modules 0 to 15, on the base Keycloak, never see SPIFFE +# config: # # 1. a SPIFFE identity provider (providerId: spiffe) that validates JWT-SVIDs # from trust domain spiffe://cpex.tutorial against SPIRE's JWKS. @@ -33,7 +36,7 @@ echo "-> obtaining admin token" TOKEN=$(curl -sf -X POST "$KC/realms/master/protocol/openid-connect/token" \ -d grant_type=password -d client_id=admin-cli \ -d username="$ADMIN" -d password="$ADMIN_PW" \ - | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') + | jq -r .access_token) # --- 1. SPIFFE identity provider ------------------------------------------- read -r -d '' IDP_JSON < 0 then .[0].id else "" end') if [ -n "$CID" ]; then echo "-> removing existing client '$CLIENT_ID' before recreate" curl -sf -X DELETE -H "Authorization: Bearer $TOKEN" "$KC/admin/realms/$REALM/clients/$CID"