Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/content/docs/tutorial/06-delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -86,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

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/tutorial/10-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
105 changes: 105 additions & 0 deletions docs/content/docs/tutorial/11-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
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 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.

## 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.

- **`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

```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, 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

{{< 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 `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]({{< relref "/docs/configuration" >}}#groups) 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.
95 changes: 95 additions & 0 deletions docs/content/docs/tutorial/12-subjects.md
Original file line number Diff line number Diff line change
@@ -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's, or the gateway's own.

## The problem

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

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

## Build it

Two routes, two subjects. From [`policies/m12.yaml`](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` 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

```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 { ... }
```

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

## Try it

1. **Swap the subjects.** Put `subject: this_workload` on `get_compensation` and re-run the anonymous case. Expect: it now succeeds, because the gateway no longer needs the caller's token.
2. **Ask for a caller that isn't there.** Add `authentication: [keycloak]` to `search_repos` and re-run it anonymously. Expect: the denial moves earlier, to identity resolution, even though the delegation itself never needed a caller.
3. **Drop the audience mapper (advanced).** `subject: this_workload` relies on the `cpex-gateway` client being a service account with an audience mapper for `github-api` (see [`idp/realm-export.json`](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 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 same rule covers the other subjects, each with its own module:

- **`subject: client`**, the calling OAuth client acting as itself, its own token scoped down: [module 13]({{< 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 the reference recipes, 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.
98 changes: 98 additions & 0 deletions docs/content/docs/tutorial/13-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
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

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`, 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, 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.

## 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: 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 in 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 `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, 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; 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`.
{{< /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, forwarding the caller's token unchanged instead of minting one.
Loading
Loading