Skip to content

Commit ebb1b5c

Browse files
teryltaraujof
andauthored
docs: add new docs: tutorials on identity and static attributes (#167)
* docs: add new tutorials on identity and static attributes. Signed-off-by: Teryl Taylor <terylt@ibm.com> * 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 <frederico.araujo@ibm.com> * 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 <frederico.araujo@ibm.com> * 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 <frederico.araujo@ibm.com> * 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 <frederico.araujo@ibm.com> * 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 <frederico.araujo@ibm.com> * 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 <frederico.araujo@ibm.com> --------- Signed-off-by: Teryl Taylor <terylt@ibm.com> Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com> Co-authored-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent b9d31de commit ebb1b5c

41 files changed

Lines changed: 2728 additions & 47 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,16 @@ examples-run: examples-build tutorial-check-local
331331
# modules that need no infrastructure; `tutorial-check` additionally brings
332332
# up the tutorial Keycloak (docker compose) and runs the IdP-backed modules,
333333
# tearing the stack down afterward. CI runs `tutorial-check`.
334+
#
335+
# Module 16 needs the SPIRE overlay and a one-time Keycloak setup on top of
336+
# the base stack, so it has its own opt-in target (`tutorial-check-spire`).
334337

335338
TUTORIAL_IDP_COMPOSE = examples/tutorial/idp/docker-compose.yml
339+
TUTORIAL_SPIRE_COMPOSE = examples/tutorial/idp/docker-compose.spire.yml
336340
TUTORIAL_NOIDP_MODULES = m01_hello m03_shaping m04_effects m09_custom_plugin m10_testing
337-
TUTORIAL_IDP_MODULES = m02_identity m05_pdp m06_delegation m07_tainting m08_elicitation capstone
341+
TUTORIAL_IDP_MODULES = m02_identity m05_pdp m06_delegation m07_tainting m08_elicitation \
342+
m11_groups m12_subjects m13_client m14_passthrough m15_dual_principal \
343+
m17_federation m18_attributes capstone
338344

339345
.PHONY: tutorial-check-local
340346
tutorial-check-local:
@@ -359,6 +365,24 @@ tutorial-check: tutorial-check-local
359365
@docker compose -f $(TUTORIAL_IDP_COMPOSE) down
360366
@echo "✅ Tutorial checks passed (incl. IdP-backed modules)"
361367

368+
# Module 16 only. Brings up the SPIRE overlay, trusts SPIRE in Keycloak, and
369+
# runs the workload-identity module. Not part of the CI gate: it needs two
370+
# extra containers and a Keycloak that speaks SPIFFE.
371+
.PHONY: tutorial-check-spire
372+
tutorial-check-spire:
373+
@echo "→ starting tutorial IdP + SPIRE"
374+
@docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) up -d
375+
@echo "→ waiting for Keycloak realm to be ready"
376+
@$(CARGO) run -q -p cpex-tutorial --example wait_for_idp || { \
377+
docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down; exit 1; }
378+
@examples/tutorial/idp/spire/setup-spiffe.sh || { \
379+
docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down; exit 1; }
380+
@echo "→ tutorial m16_workload --check"
381+
@$(CARGO) run -q -p cpex-tutorial --example m16_workload -- --check || { \
382+
docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down; exit 1; }
383+
@docker compose -f $(TUTORIAL_IDP_COMPOSE) -f $(TUTORIAL_SPIRE_COMPOSE) down
384+
@echo "✅ Tutorial SPIRE check passed (module 16)"
385+
362386
.PHONY: tutorial-recordings
363387
tutorial-recordings:
364388
@examples/tutorial/recordings/record.sh

docs/content/docs/tutorial/06-delegation.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ weight: 7
66
# Module 6: Scoped credentials (Delegation)
77

88
> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP.
9+
>
10+
> **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" >}}).
911
1012
**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.
1113

@@ -86,6 +88,7 @@ Keycloak mints the scoped token during the exchange, constrained by the requeste
8688
## Go deeper
8789
8890
- [Delegation]({{< relref "/docs/apl/delegation" >}}) for token exchange, capability reduction, and downstream verification.
91+
- [Module 12: Delegation subjects]({{< relref "12-subjects" >}}) for the other principals a minted token can speak for.
8992
9093
## Next
9194

docs/content/docs/tutorial/10-testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@ The `Outcome` from `mediate()`: allowed or denied, and for denials the reason co
8282

8383
## Next
8484

85-
[Capstone: the three-backend agent]({{< relref "capstone" >}}): assemble every control you have built into the full Overview scenario.
85+
[Module 11: Organizing policy]({{< relref "11-groups" >}}): factor the setup your routes share into reusable groups.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
title: "Organizing policy (Groups)"
3+
weight: 12
4+
---
5+
6+
# Module 11: Organizing policy (Groups)
7+
8+
> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP.
9+
10+
**Goal:** factor the setup every route shares, here the identity resolver, into one reusable group that routes join instead of repeating.
11+
12+
## The problem
13+
14+
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.
15+
16+
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.
17+
18+
## Build it
19+
20+
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):
21+
22+
```yaml
23+
plugins:
24+
- name: keycloak
25+
kind: identity/jwt
26+
hooks: [identity.resolve]
27+
config: { ... as in module 2 ... }
28+
29+
# One reusable bundle: the resolver every route needs, written once.
30+
groups:
31+
identified:
32+
authentication:
33+
- keycloak
34+
35+
routes:
36+
- tool: get_compensation
37+
groups: identified # inherits keycloak; no authentication: line
38+
authorization:
39+
pre_invocation:
40+
- "require(role.hr)"
41+
- tool: search_repos
42+
groups: identified
43+
authorization:
44+
pre_invocation:
45+
- "require(role.engineer)"
46+
- tool: send_email
47+
groups: identified # still resolves the token; no role required
48+
authorization:
49+
pre_invocation:
50+
- "require(authenticated)"
51+
```
52+
53+
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.
54+
55+
- **`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.
56+
- **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.
57+
58+
## Run it
59+
60+
```bash
61+
cargo run -p cpex-tutorial --example m11_groups
62+
```
63+
64+
```
65+
▸ alice (hr) → get_compensation (group resolves her token, require(role.hr) passes)
66+
✓ ALLOWED { ... }
67+
68+
▸ evan (engineer) → get_compensation (resolved by the same group, denied at require(role.hr))
69+
✗ DENIED [...] access denied
70+
71+
▸ evan (engineer) → search_repos (same group, require(role.engineer) passes)
72+
✓ ALLOWED { ... }
73+
74+
▸ alice (hr) → search_repos (denied at require(role.engineer))
75+
✗ DENIED [...] access denied
76+
77+
▸ alice (hr) → send_email (group resolves her token; require(authenticated) passes)
78+
✓ ALLOWED { ... }
79+
```
80+
81+
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.
82+
83+
## Try it
84+
85+
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.
86+
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.
87+
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.
88+
89+
## Checkpoint
90+
91+
{{< details "Does the route repeat the group's authentication?" >}}
92+
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.
93+
{{< /details >}}
94+
95+
{{< details "Group or the reserved `all` group?" >}}
96+
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.
97+
{{< /details >}}
98+
99+
## Go deeper
100+
101+
- [Configuration]({{< relref "/docs/configuration" >}}#groups) for the full `groups:` schema, defaults, and how membership resolves.
102+
103+
## Next
104+
105+
[Module 12: Delegation subjects]({{< relref "12-subjects" >}}): choose whether a minted token speaks for the caller or the gateway itself.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
title: "Delegation subjects"
3+
weight: 13
4+
---
5+
6+
# Module 12: Delegation subjects (who the call speaks for)
7+
8+
> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP.
9+
>
10+
> **Cookbook recipes:** [Recipe 1: on-behalf-of a user]({{< relref "/docs/identity-delegation#recipe-1-user-acting-through-an-agent-on-behalf-of" >}}) (`subject: user`) and [Recipe 3: a service acting as itself]({{< relref "/docs/identity-delegation#recipe-3-a-service-acting-as-itself" >}}) (`subject: this_workload`).
11+
12+
**Goal:** choose *whose* authority a minted downstream token carries: the caller's, or the gateway's own.
13+
14+
## The problem
15+
16+
Not every downstream call has a user behind it. A scheduled sync, a shared index lookup, an infrastructure call: there the *gateway* holds the downstream credential and calls as itself, with no caller in the picture. Module 6 minted on behalf of the caller, which is the right default when the agent acts for a signed-in human, and the wrong one here.
17+
18+
The `subject:` argument on a `delegate(...)` step picks which principal the minted token speaks for. The delegation *mode* is derived from it, never declared separately, so a route can't claim to act on-behalf-of-a-user while actually handing over some other credential.
19+
20+
## Build it
21+
22+
Two routes, two subjects. From [`policies/m12.yaml`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/policies/m12.yaml):
23+
24+
```yaml
25+
routes:
26+
# On behalf of the caller: exchange the CALLER's token.
27+
- tool: get_compensation
28+
authentication: [keycloak]
29+
authorization:
30+
pre_invocation:
31+
- "delegate(workday-oauth, target: workday-api, audience: workday-api, subject: user)"
32+
- "require(delegation.granted)"
33+
34+
# As the gateway itself: mint via the gateway's own client credentials.
35+
- tool: search_repos
36+
authorization:
37+
pre_invocation:
38+
- "delegate(workday-oauth, target: github-api, audience: github-api, subject: this_workload)"
39+
- "require(delegation.granted)"
40+
```
41+
42+
- **`subject: user`** (the default) runs an RFC 8693 token exchange on the caller's inbound token. No caller token, nothing to exchange.
43+
- **`subject: this_workload`** runs an RFC 6749 `client_credentials` grant with the gateway's own `client_id` and secret, through the same `workday-oauth` plugin, reading no caller token at all. The `search_repos` route has no `authentication:` line, because this path needs no caller.
44+
45+
## Run it
46+
47+
```bash
48+
cargo run -p cpex-tutorial --example m12_subjects
49+
```
50+
51+
```
52+
▸ alice → get_compensation (subject: user, exchanges alice's token)
53+
✓ ALLOWED { ... }
54+
55+
▸ anonymous → get_compensation (subject: user, no token to exchange, delegation fails)
56+
✗ DENIED [delegation.bad_request] ... empty bearer_token ...
57+
58+
▸ anonymous → search_repos (subject: this_workload, gateway mints via client_credentials)
59+
✓ ALLOWED { ... }
60+
61+
▸ alice → search_repos (subject: this_workload, same result: the caller's identity is not used)
62+
✓ ALLOWED { ... }
63+
```
64+
65+
`subject: user` needs a caller: the anonymous request fails at the exchange with an empty token. `subject: this_workload` needs none, and succeeds anonymously, because the gateway holds the credential.
66+
67+
## Try it
68+
69+
1. **Swap the subjects.** Put `subject: this_workload` on `get_compensation` and re-run the anonymous case. Expect: it now succeeds, because the gateway no longer needs the caller's token.
70+
2. **Ask for a caller that isn't there.** Add `authentication: [keycloak]` to `search_repos` and re-run it anonymously. Expect: the denial moves earlier, to identity resolution, even though the delegation itself never needed a caller.
71+
3. **Drop the audience mapper (advanced).** `subject: this_workload` relies on the `cpex-gateway` client being a service account with an audience mapper for `github-api` (see [`idp/realm-export.json`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). Remove that mapper and the minted token won't carry the audience.
72+
73+
## Checkpoint
74+
75+
{{< details "Why does anonymous fail on one route but not the other?" >}}
76+
`subject: user` exchanges the caller's inbound token; an anonymous request has none, so the exchange fails with `delegation.bad_request`. `subject: this_workload` uses the gateway's own client credentials, so there is nothing about the caller it needs.
77+
{{< /details >}}
78+
79+
{{< details "Is the mode declared or derived?" >}}
80+
Derived. You choose `subject:`, and the delegation mode (on-behalf-of vs. act-as-self) follows from it. There is no separate `mode:` key, so a route can't claim on-behalf-of-user while handing over the gateway's own credential.
81+
{{< /details >}}
82+
83+
## Go deeper
84+
85+
The same rule covers the other subjects, each with its own module:
86+
87+
- **`subject: client`**, the calling OAuth client acting as itself, its own token scoped down: [module 13]({{< relref "13-client" >}}).
88+
- **`subject: caller_workload`**, the calling agent proving itself with a SPIFFE JWT-SVID, exchanged in two legs: [module 16]({{< relref "16-workload" >}}), which needs the SPIRE overlay.
89+
- **`actor:`**, recording the calling agent alongside the user `sub`: [module 15]({{< relref "15-dual-principal" >}}).
90+
91+
See the [Identity & Delegation cookbook]({{< relref "/docs/identity-delegation" >}}) for the reference recipes, and the [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` / `actor:` contract.
92+
93+
## Next
94+
95+
[Module 13: Delegation as a client]({{< relref "13-client" >}}): scope a token an agent minted for *itself*, when the caller is an OAuth client rather than a user.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
title: "Delegation as a client"
3+
weight: 14
4+
---
5+
6+
# Module 13: Delegation `subject: client` (the agent scopes its own token)
7+
8+
> You are in the [CPEX tutorial]({{< relref "_index" >}}). This module needs the IdP.
9+
>
10+
> **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" >}}).
11+
12+
**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.
13+
14+
## The problem
15+
16+
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.
17+
18+
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:
19+
20+
| Subject | Whose token is exchanged? | Needs a caller token? |
21+
|---|---|---|
22+
| `user` (module 6, 12) | the signed-in user's | yes |
23+
| `client` (this module) | the calling agent's own | yes |
24+
| `this_workload` (module 12) | none, the gateway mints its own | no |
25+
26+
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.
27+
28+
## Build it
29+
30+
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):
31+
32+
```yaml
33+
plugins:
34+
# role: client routes the inbound token to the `client` slot (not `subject`).
35+
- name: keycloak-agent
36+
kind: identity/jwt
37+
hooks: [identity.resolve]
38+
config:
39+
role: client # <-- the agent as an OAuth client
40+
header: Authorization
41+
claim_mapper: standard
42+
trusted_issuers:
43+
- issuer: http://localhost:8081/realms/cpex-tutorial
44+
audiences: [cpex-gateway] # the agent's token is aud'd to the gateway
45+
algorithms: [RS256]
46+
decoding_key: { kind: jwks_url, url: "…/certs", insecure_http: true }
47+
48+
routes:
49+
- tool: search_repos
50+
authentication: [keycloak-agent]
51+
authorization:
52+
pre_invocation:
53+
- "delegate(workday-oauth, target: github-api, audience: github-api, subject: client)"
54+
- "require(delegation.granted)"
55+
```
56+
57+
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.
58+
59+
## Run it
60+
61+
```bash
62+
cargo run -p cpex-tutorial --example m13_client
63+
```
64+
65+
```
66+
▸ agent (cpex-agent client) → search_repos (subject: client, scopes the agent's own token)
67+
✓ ALLOWED { ... "repositories":[ ... ] }
68+
69+
▸ anonymous → search_repos (subject: client, no client token to exchange, delegation fails)
70+
✗ DENIED [delegation.bad_request] ... empty bearer_token ...
71+
```
72+
73+
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`.
74+
75+
## Try it
76+
77+
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.
78+
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`).
79+
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.
80+
81+
## Checkpoint
82+
83+
{{< details "Why does anonymous fail here but succeed under this_workload?" >}}
84+
`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.
85+
{{< /details >}}
86+
87+
{{< details "Is this one leg or two?" >}}
88+
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`.
89+
{{< /details >}}
90+
91+
## Go deeper
92+
93+
- [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.
94+
- [Delegation reference]({{< relref "/docs/apl/delegation" >}}) for the full `subject:` contract.
95+
96+
## Next
97+
98+
[Module 14: Passthrough]({{< relref "14-passthrough" >}}): the opposite move, forwarding the caller's token unchanged instead of minting one.

0 commit comments

Comments
 (0)