Module 2: Who’s calling? (Identity)#
You are in the CPEX tutorial. This module needs the IdP. Start it first:
docker compose -f examples/tutorial/idp/docker-compose.yml up -d
Goal: resolve a real bearer token into a subject (an id, roles, and permissions) so authorization predicates have something to read.
The problem#
In module 1, require(role.hr) denied everyone because nobody had a role. Roles and permissions come from identity: a verified token the caller presents. CPEX turns that token into attributes policy can gate on, and it must do so without trusting anything the caller could forge.
Build it#
Add an identity plugin and reference it from the route. From policies/m02.yaml:
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 # localhost speaks http; never in production
leeway_seconds: 60
routes:
- tool: get_compensation
authentication:
- keycloak
authorization:
pre_invocation:
- "require(authenticated)"
- "require(role.hr)"The plugin validates the JWT offline against the realm’s signing keys, fetched once from the JWKS url and cached. It checks issuer, audience, expiry, and signature. A token that fails any check is rejected before any authorization rule runs. The realm emits flat roles and permissions claims (see idp/README.md). The standard mapper turns roles: ["hr"] into role.hr = true and permissions: ["view_ssn"] into perm.view_ssn = true.
The snippet is abbreviated; the file also sets role, header, and the JWKS refresh_secs. The tutorial personas: alice (hr and view_ssn), dana (hr, no view_ssn), evan (engineer), sam (security).
Run it#
cargo run -p cpex-tutorial --example m02_identity▸ alice (hr) → get_compensation
✓ ALLOWED { ... }
▸ evan (engineer) → get_compensation (fails require(role.hr))
✗ DENIED [...] access denied
▸ garbage token → get_compensation (rejected at validation)
✗ DENIED [auth.malformed_header] ...The harness mints each persona’s token, meaning it obtains a signed JWT for them from Keycloak with a password grant (see src/idp.rs), then calls the same route. Identity, not code, splits the outcomes.
Try it#
- Swap personas. In
examples/tutorial/examples/m02_identity.rs, replace theevanscenario withdana(hr, noview_ssn). Three edits: change the token line tolet dana = idp::mint_token("dana", "dana")...(rename theevanbinding todana), update theui::scenario("evan (engineer) → ...")label todana (hr) → get_compensation, and pass&danato thatmediate(...)call. Re-run. Expect: dana is allowed too (she is hr); theview_ssndifference does not matter until module 3. - Break the audience. In
policies/m02.yaml, changeaudiences: [cpex-tutorial]to[some-other-api]and re-run. Expect: alice and evan now deny withauth.audience_mismatch. The garbage token still fails earlier atauth.malformed_header: it is not a valid JWT, so audience is never checked. - Inspect a token. Mint one by hand and decode its claims (works on Linux and macOS, jq only):Look for the flat
TOKEN=$(curl -s http://localhost:8081/realms/cpex-tutorial/protocol/openid-connect/token \ -d grant_type=password -d client_id=cpex-tutorial \ -d username=alice -d password=alice | jq -r .access_token) echo "$TOKEN" | cut -d. -f2 | jq -Rr '. + "=="[:(4 - length % 4) % 4] | @base64d' | jq .roles,permissions, andaudclaims the realm’s mappers produced. Seeidp/README.mdfor all the personas and their passwords.
Checkpoint#
Does CPEX call Keycloak on every request?
Why is the garbage token denied with an auth code, not an authorization code?
Go deeper#
- Identity & IdP for resolvers, the attribute bag, and claim mapping.
Next#
Module 3: Shaping data: now that callers differ by permission, return a different view of the same record to each.