diff --git a/docs/content/docs/configuration.md b/docs/content/docs/configuration.md index b33296e8..3e5dd4ce 100644 --- a/docs/content/docs/configuration.md +++ b/docs/content/docs/configuration.md @@ -18,10 +18,11 @@ plugins: # the plugins available to policy, by kind config: { ... } global: # cross-cutting resolvers and stores - pdp: - - kind: ... - session_store: - kind: ... + apl: + pdp: + - kind: ... + session_store: + kind: ... routes: # APL policy, a list of operations - tool: # or resource: / prompt: / llm: @@ -72,18 +73,19 @@ plugins: ## Global -`global.pdp` registers PDP resolvers; `global.session_store` selects where taint labels live (absent it, the in-process memory store is used). +`global.apl.pdp` registers PDP resolvers; `global.apl.session_store` selects where taint labels live (absent it, the in-process memory store is used). ```yaml global: - pdp: - - kind: cedar-direct - policy_text: | - permit(principal, action == Action::"read", resource is Repo) - when { principal.roles.contains("security") }; - session_store: - kind: valkey - endpoint: localhost:6379 + apl: + pdp: + - kind: cedar-direct + policy_text: | + permit(principal, action == Action::"read", resource is Repo) + when { principal.roles.contains("security") }; + session_store: + kind: valkey + endpoint: localhost:6379 ``` ## Routes diff --git a/docs/content/docs/overview.md b/docs/content/docs/overview.md index 06f87730..f4b45469 100644 --- a/docs/content/docs/overview.md +++ b/docs/content/docs/overview.md @@ -11,6 +11,17 @@ Picture one agent serving several people. It answers questions by calling tools ![One agent serves three users across HR, repo, and email backends; CPEX policy produces a different outcome per identity](images/demo_scenario.png) +The **R1–R6** labels in the diagram are the six requirements this scenario places on the enforcement point. Each is a control CPEX applies, and each maps to a later page: + +| Label | Requirement | Where | +|-------|-------------|-------| +| R1 | Resolve the real user behind the agent | [Identity]({{< relref "/docs/apl/identity" >}}) | +| R2 | Same request, different data (redact per identity) | [Effects]({{< relref "/docs/apl/effects" >}}) | +| R3 | Enforce on inputs and results (validate args, shape output) | [APL]({{< relref "/docs/apl" >}}) | +| R4 | Delegate downstream with a narrower credential | [Delegation]({{< relref "/docs/apl/delegation" >}}) | +| R5 | Remember the session (carry state across calls) | [Session Tainting]({{< relref "/docs/apl/tainting" >}}) | +| R6 | Out-of-band elicitations (human approval) | [Elicitation]({{< relref "/docs/apl/elicitation" >}}) | + The agent's LLM decides which operation to run. It is untrusted. CPEX sits between it and every capability, and decides what actually happens. For each operation, CPEX resolves the caller's identity, evaluates the APL policy attached to that operation, and applies the resulting effects before anything reaches the backend. The same four phases run every time: validate arguments, evaluate policy, transform the result, run post-policy checks. ## Same request, different data diff --git a/docs/content/docs/quickstart.md b/docs/content/docs/quickstart.md index a397b0c0..3c9fdb9f 100644 --- a/docs/content/docs/quickstart.md +++ b/docs/content/docs/quickstart.md @@ -7,6 +7,8 @@ weight: 20 This walks through standing up CPEX as an enforcement point and running the [scenario]({{< relref "/docs/overview" >}}): the `get_employee` route that authorizes by role and redacts a field by permission. +You need Rust 1.96 or newer ([install with rustup](https://rustup.rs)). Section 4 runs the tutorial's first module, which lives in the repo, so clone it first: `git clone https://github.com/contextforge-org/cpex.git && cd cpex`, and run `cargo` commands from that root. + ## 1. Add CPEX ```bash diff --git a/docs/content/docs/tutorial/00-setup.md b/docs/content/docs/tutorial/00-setup.md index c8454c13..8e51c0b9 100644 --- a/docs/content/docs/tutorial/00-setup.md +++ b/docs/content/docs/tutorial/00-setup.md @@ -15,6 +15,18 @@ CPEX is a policy enforcement runtime for AI agents. It is a deterministic refere You are the host: the process that embeds CPEX and drives the loop of resolving identity, running policy, calling the backend, and running policy again on the result. The tutorial harness wraps that loop in a single `mediate()` call so you can focus on policy. Module 9 opens `mediate()` up and shows the real dispatch API underneath. +## Get the code + +The tutorial runs from a clone of the CPEX repository (not from a published crate), so you can edit the policies and examples as you go. + +- Install Rust 1.96 or newer with [rustup](https://rustup.rs) (gives you `rustc` and `cargo`). +- Clone the repo and move into it: + ```bash + git clone https://github.com/contextforge-org/cpex.git + cd cpex + ``` +- Run every `cargo run -p cpex-tutorial ...` command from the repository root. + ## Check your setup ```bash diff --git a/docs/content/docs/tutorial/01-hello.md b/docs/content/docs/tutorial/01-hello.md index e276dad4..68be97a2 100644 --- a/docs/content/docs/tutorial/01-hello.md +++ b/docs/content/docs/tutorial/01-hello.md @@ -59,9 +59,9 @@ Same anonymous caller, same host code. The route decided the outcome, and the de ## Try it -1. Change the failing predicate. In `policies/m01.yaml`, change `require(authenticated)` to `require(role.hr)` and re-run. Expect: `get_compensation` still denies, but the reason points at the new rule, since nobody has a role yet. +1. Change the failing predicate. In `examples/tutorial/policies/m01.yaml`, change `require(authenticated)` to `require(role.hr)` and re-run. Expect: `get_compensation` still denies, because the anonymous caller has no role either. The displayed reason is the same generic, position-based code (`...pre_invocation[0]] access denied`); what changed is which predicate rejected the call, not the visible text. 2. Open the gated route. Delete the `require(authenticated)` line (leave `pre_invocation: []`) and re-run. Expect: both calls allow. -3. Gate the open route. Add `- "require(authenticated)"` under `search_repos` and re-run. Expect: both calls deny. +3. Gate the open route. `get_compensation` already denies; add `- "require(authenticated)"` under `search_repos` too, leaving `get_compensation` unchanged, and re-run. Expect: both calls now deny. Reset any time with `git checkout -- examples/tutorial/policies`. diff --git a/docs/content/docs/tutorial/02-identity.md b/docs/content/docs/tutorial/02-identity.md index cd62476a..1f902206 100644 --- a/docs/content/docs/tutorial/02-identity.md +++ b/docs/content/docs/tutorial/02-identity.md @@ -47,7 +47,7 @@ routes: 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`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)). The `standard` mapper turns `roles: ["hr"]` into `role.hr = true` and `permissions: ["view_ssn"]` into `perm.view_ssn = true`. -The tutorial personas: alice (hr and `view_ssn`), dana (hr, no `view_ssn`), evan (engineer), sam (security). +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 @@ -66,13 +66,20 @@ cargo run -p cpex-tutorial --example m02_identity ✗ DENIED [auth.malformed_header] ... ``` -The harness mints each persona's token from Keycloak with a password grant (see `src/idp.rs`), then calls the same route. Identity, not code, splits the outcomes. +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 -1. Swap personas. In `m02_identity.rs`, mint `dana` instead of `evan`. Expect: dana (also hr) is allowed. The `view_ssn` difference does not matter until module 3. -2. Break the audience. In the policy, change `audiences: [cpex-tutorial]` to `[some-other-api]` and re-run. Expect: every call denies with `auth.audience_mismatch`, because validation fails before authorization. -3. Inspect a token. Mint one by hand (`idp/README.md` has the curl) and decode it to see the `roles`, `permissions`, and `aud` claims the mappers produced. +1. Swap personas. In `examples/tutorial/examples/m02_identity.rs`, replace the `evan` scenario with `dana` (hr, no `view_ssn`). Three edits: change the token line to `let dana = idp::mint_token("dana", "dana")...` (rename the `evan` binding to `dana`), update the `ui::scenario("evan (engineer) → ...")` label to `dana (hr) → get_compensation`, and pass `&dana` to that `mediate(...)` call. Re-run. Expect: dana is allowed too (she is hr); the `view_ssn` difference does not matter until module 3. +2. Break the audience. In `policies/m02.yaml`, change `audiences: [cpex-tutorial]` to `[some-other-api]` and re-run. Expect: alice and evan now deny with `auth.audience_mismatch`. The garbage token still fails earlier at `auth.malformed_header`: it is not a valid JWT, so audience is never checked. +3. Inspect a token. Mint one by hand and decode its claims (works on Linux and macOS, jq only): + ```bash + 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 . + ``` + Look for the flat `roles`, `permissions`, and `aud` claims the realm's mappers produced. See [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp) for all the personas and their passwords. ## Checkpoint diff --git a/docs/content/docs/tutorial/03-shaping.md b/docs/content/docs/tutorial/03-shaping.md index 86991501..f9f1a71a 100644 --- a/docs/content/docs/tutorial/03-shaping.md +++ b/docs/content/docs/tutorial/03-shaping.md @@ -49,9 +49,18 @@ The call is allowed, so the record still comes back, but `ssn` and `salary` are ## Try it -1. See the full record. Start the IdP and change the example to mint `alice` (hr and `view_ssn`). Expect: `ssn` and `salary` come back in full, `employee_id` still masked. Same policy, different caller. -2. Split the two fields. Mint `dana` (hr, no `view_ssn`). Expect: `salary` is visible (she has `role.hr`) but `ssn` is redacted (she lacks `view_ssn`). The two redactions gate independently. -3. Change the mask width. Set `employee_id: "str | mask(2)"`. Expect: only the last two characters survive. +1. Redact another field. In `examples/tutorial/policies/m03.yaml`, add a line under `result:` such as `name: "str | redact(!perm.view_ssn)"`, then re-run. Expect: `name` now comes back `[REDACTED]` too. The pipeline transforms exactly the fields you name. +2. See the full record with identity. The default run is anonymous, so every redaction fires (no caller has `view_ssn`). To see the per-permission contrast, give the route an identity, the same way module 2 does. This is a two-file change: + - In `examples/tutorial/policies/m03.yaml`, add the `keycloak` identity plugin and reference it from the route. Copy the top-level `plugins:` block and the `authentication:` line from `policies/m02.yaml` (module 3's policy has no identity plugin on its own, which is why minting a token alone changes nothing). + - In `examples/tutorial/examples/m03_shaping.rs`, add `use cpex_tutorial::idp;` near the other imports and replace `let caller = Caller::anonymous();` with: + ```rust + let caller = match idp::mint_token("alice", "alice").await { + Ok(t) => Caller::with_token(t), + Err(e) => { eprintln!("{e}"); std::process::exit(1); } + }; + ``` + - Start the IdP and re-run. Expect: alice (`view_ssn`) now sees the full `ssn` and `salary`, with `employee_id` still masked. Swap `"alice"` for `"dana"` and `ssn` is redacted again (she lacks `view_ssn`) while `salary` stays visible. Same policy, different caller. This is the contrast the [capstone]({{< relref "capstone" >}}) runs end to end. +3. Change the mask width. Set `employee_id: "str | mask(2)"` and re-run. Expect: only the last two characters survive. ## Checkpoint diff --git a/docs/content/docs/tutorial/04-effects.md b/docs/content/docs/tutorial/04-effects.md index b940d068..1d031ebc 100644 --- a/docs/content/docs/tutorial/04-effects.md +++ b/docs/content/docs/tutorial/04-effects.md @@ -81,4 +81,4 @@ For a hand-written guard, the second argument to `deny('reason', 'code')`. For a ## Next -Modules 5 through 10 continue with PDPs, scoped credentials, information flow, human-in-the-loop, custom plugins, and testing, ending in the capstone that reassembles the whole three-backend scenario. +[Module 5: Delegating decisions]({{< relref "05-pdp" >}}): hand a decision to CEL or Cedar and let CPEX enforce the verdict. diff --git a/docs/content/docs/tutorial/05-pdp.md b/docs/content/docs/tutorial/05-pdp.md index adf40bff..6046ac19 100644 --- a/docs/content/docs/tutorial/05-pdp.md +++ b/docs/content/docs/tutorial/05-pdp.md @@ -60,7 +60,7 @@ One expression captured a rule that mixes role and argument. The PDP decided, an ## Try it 1. Loosen the engineer rule. Change the expression to also allow engineers to read public repos. Re-run and confirm evan's public search now allows. -2. Break a guard. Remove a `has(...)` guard and run as sam. Expect: sam is denied, because the expression errors on the unset `role.engineer` and fails closed. +2. Why the guards matter. Remove the `has(role.engineer)` guard from the expression and re-run. Expect: sam is still allowed. `role.security` is true, and CEL's `||` yields true even though the other side now errors on the unset `role.engineer` (CEL absorbs an error when the other operand is true). The guard exists so the expression stays well-defined for every caller: a caller who is neither engineer nor security (an `hr` user like alice) would hit that evaluation error with nothing to absorb it and be denied fail-closed. 3. Change the deny code. Edit the `on_deny` code and confirm the reason code changes. ## Checkpoint diff --git a/docs/content/docs/tutorial/06-delegation.md b/docs/content/docs/tutorial/06-delegation.md index c3f1131e..c1ce7b22 100644 --- a/docs/content/docs/tutorial/06-delegation.md +++ b/docs/content/docs/tutorial/06-delegation.md @@ -71,7 +71,7 @@ alice's call runs a real token exchange and gets a `workday-api`-scoped token be 1. Drop the capabilities. Remove the `capabilities:` line from the plugin and re-run. Expect: alice is denied with `delegation.bad_request` (empty token), because the inbound credential was filtered out. 2. Wrong audience. Change `audience:` to a client the gateway may not target and re-run. Expect: the exchange is rejected by Keycloak and the step denies. -3. Inspect the exchange. Add a `permissions: [read_compensation]` arg and set up the matching client scope in the realm to see a scoped-down token (this is how you narrow the downstream grant). +3. Narrow the grant (advanced). In `policies/m06.yaml`, add a `permissions:` argument to the `delegate(...)` step so it reads `delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])`. The exchange then requests only that scope. For Keycloak to actually issue it, the `read_compensation` client scope must exist on the realm and be assigned to the `workday-api` client (see [`idp/README.md`](https://github.com/contextforge-org/cpex/tree/main/examples/tutorial/idp)); without that realm setup the exchange returns the default scope. This is how you narrow the downstream grant to exactly what the operation needs. ## Checkpoint @@ -89,4 +89,4 @@ Keycloak mints the scoped token during the exchange, constrained by the requeste ## Next -[Module 8: Human in the loop]({{< relref "08-elicitation" >}}) (or the capstone, which reassembles all three backends). +[Module 7: Information flow]({{< relref "07-tainting" >}}): carry security state across a session to block write-down. diff --git a/docs/content/docs/tutorial/07-tainting.md b/docs/content/docs/tutorial/07-tainting.md index 554b7b0b..993158e4 100644 --- a/docs/content/docs/tutorial/07-tainting.md +++ b/docs/content/docs/tutorial/07-tainting.md @@ -59,9 +59,20 @@ The same `send_email` call is allowed in a clean session and denied in the sessi ## Try it -1. Separate the sessions. Give the third call a new session id. Expect: it allows again, because taint is per session. -2. Taint from elsewhere. Add `taint(secret, session)` to a different route and confirm reading through that route also blocks later email. -3. Persist across restarts. Start Valkey (`docker compose -f examples/tutorial/idp/docker-compose.yml --profile valkey up -d`) and point the session store at it, so taint survives a process restart. The in-memory store used here resets when the program exits. +1. Separate the sessions. In `examples/tutorial/examples/m07_tainting.rs`, change the third call's `.in_session("session-hr-work")` to a fresh id like `.in_session("session-new")` and re-run. Expect: the email allows again, because taint is per session. +2. Rename the label. In `examples/tutorial/policies/m07.yaml`, change `taint(secret, session)` to `taint(pii, session)` and update the guard `security.labels contains "secret"` to `"pii"`. Re-run. Expect: the write-down still blocks, now keyed on the `pii` label. Labels are arbitrary names you choose; the write and the read just have to agree. +3. Persist across restarts. The default store is in-memory and resets when the program exits. To back it with Valkey: + - Start Valkey: `docker compose -f examples/tutorial/idp/docker-compose.yml --profile valkey up -d`. + - Enable the feature: in `examples/tutorial/Cargo.toml`, change the `cpex` dependency to `features = ["builtins", "valkey"]`. + - In `policies/m07.yaml`, add a session store under `global`: + ```yaml + global: + apl: + session_store: + kind: valkey + endpoint: localhost:6379 + ``` + Re-run: the taint now lives in Valkey and survives a process restart. ## Checkpoint diff --git a/docs/content/docs/tutorial/08-elicitation.md b/docs/content/docs/tutorial/08-elicitation.md index 6a065fa6..68040b7c 100644 --- a/docs/content/docs/tutorial/08-elicitation.md +++ b/docs/content/docs/tutorial/08-elicitation.md @@ -48,7 +48,7 @@ match payload.operation() { } ``` -The channel is served over HTTP so a human can approve with `curl`. In production this would be an OIDC CIBA backchannel or a push to the approver's phone. The point here is CPEX's suspend and resume model, not the notification transport. +The channel is served over HTTP so a human can approve with `curl`. In production this would be an OIDC CIBA backchannel or a push to the approver's phone. CIBA (Client-Initiated Backchannel Authentication, [OpenID spec](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)) is the OpenID flow where the app asks the identity provider to prompt a user on a separate device and polls for their decision; see [Human-in-the-Loop Elicitation]({{< relref "/docs/apl/elicitation" >}}) for how CPEX drives it. The point here is CPEX's suspend and resume model, not the notification transport. ## Run it @@ -66,22 +66,24 @@ The first attempt suspends: curl -X POST localhost:8090/approvals/elic-mona/approve ``` -Run that curl in a second terminal. The program is polling; once you approve, it retries and the call runs: +Run that curl in a second terminal. You do not re-run anything: the same program is polling the approval channel in a loop, so a moment after your curl (it polls on an interval, so allow a few seconds) it picks up the decision and resumes on its own: ``` ▸ evan → send_email (retry with the approval: resumes and runs) ✓ ALLOWED {"sent":true, ...} ``` +Here "retry" means that automatic re-check inside the running program, not a second `cargo run`. In a real agent it is the agent re-sending the request with the elicitation id; the tutorial harness does it for you. + Run with `-- --check` to have it approve itself and exercise the whole path unattended. {{< asciinema cast="https://asciinema.org/a/xjfOzQwrEnrLLCp8.cast" poster="npt:0:04" >}} ## Try it -1. Deny instead. Use `curl -X POST localhost:8090/approvals/elic-mona/deny`. Expect: the retry is denied, not allowed. +1. Deny instead. Use `curl -X POST localhost:8090/approvals/elic-mona/deny`. Expect: the program's next poll picks up the denial (again allow a few seconds), and the call ends denied, not allowed. 2. List pending. `curl localhost:8090/approvals` shows the open request while the program waits. -3. Retry without approving. Resume before approving and confirm the call is still pending, not allowed. +3. Let it stay pending. Do nothing. The program keeps polling and the operation never runs; it only proceeds once someone approves. There is no manual retry step, the running program resumes itself. ## Checkpoint @@ -99,4 +101,4 @@ The first attempt returns an elicitation id. The caller echoes it on retry (the ## Next -The capstone reassembles all three backends and every control into the full scenario. +[Module 9: Write your own plugin]({{< relref "09-custom-plugin" >}}): build a custom plugin with the SDK and reference it from policy. diff --git a/docs/content/docs/tutorial/09-custom-plugin.md b/docs/content/docs/tutorial/09-custom-plugin.md index 64f60c4c..4bb32b63 100644 --- a/docs/content/docs/tutorial/09-custom-plugin.md +++ b/docs/content/docs/tutorial/09-custom-plugin.md @@ -73,9 +73,16 @@ cargo run -p cpex-tutorial --example m09_custom_plugin ## Try it -1. Change the window. Set `close_hour: 23` in the policy and confirm the 22:00 call now allows. Config feeds the plugin. -2. Add a reason. Return a richer `PluginViolation` with extra detail and see it in the outcome. -3. Gate a different tool. Add `run(business-hours)` to another route and confirm the same plugin guards it. +1. Change the window. In `examples/tutorial/policies/m09.yaml`, set `close_hour: 23` and re-run. Expect: the 22:00 call now allows. Config feeds the plugin. +2. Change the denial. In `examples/tutorial/examples/m09_custom_plugin.rs`, edit the `PluginViolation::new("office.closed", ...)` call in the `Some(h)` arm, changing the code and reason strings, and re-run at hour 22. Expect: the denial line shows your new code and reason. (The tutorial's `Outcome` surfaces the code and reason; a plugin can also attach `description` and `details` on the violation, which a real host logs even though this harness does not print them.) +3. Gate a different tool. In `policies/m09.yaml`, add a second route so the same plugin guards it: + ```yaml + - tool: search_repos + authorization: + pre_invocation: + - "run(business-hours)" + ``` + Then add a call to it in `m09_custom_plugin.rs` (e.g. `mediate(&mgr, &caller, "search_repos", json!({ "visibility": "public", "hour": 22 }), backends::search_repos)`), re-run, and confirm `search_repos` is denied at hour 22 too. One plugin, many routes. ## Checkpoint @@ -93,4 +100,4 @@ Your handler returns `PluginResult::allow()` or `PluginResult::deny(violation)`. ## Next -The capstone reassembles the full three-backend scenario using the builtins and everything you have written. +[Module 10: Testing your policy]({{< relref "10-testing" >}}): write table-driven allow/deny tests that run in CI. diff --git a/docs/content/docs/tutorial/10-testing.md b/docs/content/docs/tutorial/10-testing.md index 5e8c1df2..78a0785e 100644 --- a/docs/content/docs/tutorial/10-testing.md +++ b/docs/content/docs/tutorial/10-testing.md @@ -58,8 +58,12 @@ cargo run -p cpex-tutorial --example m10_testing ## Try it -1. Break a policy. Edit `policies/m04.yaml` to drop the external-recipient guard, then run `cargo test -p cpex-tutorial`. Expect: the test fails, catching the regression. -2. Add a case. Add a row asserting an allowed call and confirm it passes. +1. Break a policy. Edit `examples/tutorial/policies/m04.yaml` to drop the external-recipient guard (the `deny(...)` line), then run `cargo test -p cpex-tutorial`. Expect: `module4_external_email_denied_with_custom_code` fails, catching the regression. +2. Add a case. In `examples/tutorial/tests/policy_tests.rs`, add a row to the `cases` array in `module1_gates_by_authentication`, for example: + ```rust + Case { tool: "search_repos", args: json!({ "visibility": "internal" }), want_allowed: true, want_code: None }, + ``` + Run `cargo test -p cpex-tutorial`. Expect: it passes (`search_repos` is open in `m01.yaml`). 3. Wire it into CI. `make test` runs the whole workspace test suite, including these. ## Checkpoint @@ -78,4 +82,4 @@ The `Outcome` from `mediate()`: allowed or denied, and for denials the reason co ## Next -The capstone reassembles the full three-backend scenario. Modules 6, 8, and 9 (delegation, elicitation, custom plugins) round out the set. +[Capstone: the three-backend agent]({{< relref "capstone" >}}): assemble every control you have built into the full Overview scenario. diff --git a/docs/content/docs/tutorial/capstone.md b/docs/content/docs/tutorial/capstone.md index 19995723..8b78d72e 100644 --- a/docs/content/docs/tutorial/capstone.md +++ b/docs/content/docs/tutorial/capstone.md @@ -17,6 +17,24 @@ One policy ([`policies/capstone.yaml`](https://github.com/contextforge-org/cpex/ - `search_repos` (repos): hand the decision to CEL: engineers read internal only, security reads any. - `send_email` (email): refuse if the session already touched secret data, and audit every attempt. +`get_compensation` is the densest route, and it runs each control you built in order: + +```yaml +- tool: get_compensation + authentication: [keycloak] # module 2: resolve the caller from a JWT + authorization: + pre_invocation: + - "require(role.hr)" # module 1/2: gate by role + - "delegate(workday-oauth, target: workday-api, audience: workday-api)" # module 6 + - "taint(secret, session)" # module 7: mark the session + - "run(audit-log)" # module 4: record the attempt + result: + ssn: "str | redact(!perm.view_ssn)" # module 3: shape the output per permission + salary: "int | redact(!role.hr)" +``` + +The one control the Overview did not show is `delegate(...)`: before the backend call, CPEX exchanges the caller's token for a fresh one scoped to the `workday-api` audience (RFC 8693, [module 6]({{< relref "06-delegation" >}})), so the backend never receives the caller's original credential. Everything else on this route you have already seen on its own; the capstone just runs them together, in sequence, on one operation. + ## Run it ```bash diff --git a/examples/tutorial/examples/m03_shaping.rs b/examples/tutorial/examples/m03_shaping.rs index 861d550e..94e7a67e 100644 --- a/examples/tutorial/examples/m03_shaping.rs +++ b/examples/tutorial/examples/m03_shaping.rs @@ -52,28 +52,20 @@ async fn main() { // The call is allowed, but the sensitive fields must be transformed. all_passed &= ui::expect(&outcome, true); - if let Outcome::Allowed { result } = &outcome { - let ssn = result.get("ssn").and_then(|v| v.as_str()).unwrap_or(""); - let salary_present = result.get("salary").and_then(|v| v.as_i64()).is_some(); - let id = result - .get("employee_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let ssn_redacted = ssn != "521-38-7710"; - let salary_redacted = !salary_present || result.get("salary") == Some(&json!(null)); - let id_masked = id != "e-1001"; - if ssn_redacted { - println!(" ssn was transformed (no view_ssn permission) ✓"); - } else { - println!(" \x1b[33m! ssn was NOT redacted\x1b[0m"); + // Field-level verification runs under --check only, so an interactive + // "Try it" run (e.g. minting alice, where the SSN is meant to appear) + // stays quiet; the printed result above already shows what was shaped. + if ui::check_mode() { + if let Outcome::Allowed { result } = &outcome { + let ssn = result.get("ssn").and_then(|v| v.as_str()).unwrap_or(""); + let id = result + .get("employee_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ssn_redacted = ssn != "521-38-7710"; + let id_masked = id != "e-1001"; + all_passed &= ssn_redacted && id_masked; } - if id_masked { - println!(" employee_id was masked ✓"); - } else { - println!(" \x1b[33m! employee_id was NOT masked\x1b[0m"); - } - all_passed &= ssn_redacted && id_masked; - let _ = salary_redacted; } println!(); diff --git a/examples/tutorial/examples/m08_elicitation.rs b/examples/tutorial/examples/m08_elicitation.rs index ffa2f238..8a5a0260 100644 --- a/examples/tutorial/examples/m08_elicitation.rs +++ b/examples/tutorial/examples/m08_elicitation.rs @@ -201,10 +201,15 @@ async fn main() { channel.resolve(&elicitation_id, true); println!(" (--check mode approved it automatically)\n"); } else { - // Interactive path: wait for the human to curl an approval. + // Interactive path: wait for the human to curl a decision. Break on + // either resolution so a denial is picked up as promptly as an + // approval; the retry below then reflects whichever it was. print!(" waiting for approval"); for _ in 0..60 { - if channel.status(&elicitation_id) == Some(Status::Approved) { + if matches!( + channel.status(&elicitation_id), + Some(Status::Approved) | Some(Status::Denied) + ) { break; } print!("."); @@ -228,8 +233,13 @@ async fn main() { ) .await; ui::print_outcome(&outcome); - all_passed &= ui::expect(&outcome, true); - println!("The operation suspended until a human approved, then resumed. The agent could not proceed alone."); + if outcome.is_allowed() { + println!("The operation suspended until a human approved, then resumed. The agent could not proceed alone."); + } else { + println!("The human denied the approval, so the suspended operation ended denied. The agent could not proceed alone."); + } + // --check drives the approve path, so the resumed call must be allowed. + all_passed &= ui::expect(&outcome, true); ui::finish_check(all_passed); } diff --git a/examples/tutorial/idp/README.md b/examples/tutorial/idp/README.md index ea0938ee..67501c5f 100644 --- a/examples/tutorial/idp/README.md +++ b/examples/tutorial/idp/README.md @@ -145,7 +145,9 @@ TOKEN=$(curl -s -X POST \ -d grant_type=password -d client_id=cpex-tutorial \ -d username=alice -d password=alice | jq -r .access_token) -echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq . +# jq-only decode: pads the base64url segment and decodes it (works on Linux and macOS, +# unlike `base64 -d`, which rejects the unpadded segment on BSD/macOS). +echo "$TOKEN" | cut -d. -f2 | jq -Rr '. + "=="[:(4 - length % 4) % 4] | @base64d' | jq . ``` For **alice** you should see `"roles": ["hr"]`, `"permissions": ["view_ssn"]`, diff --git a/examples/tutorial/src/ui.rs b/examples/tutorial/src/ui.rs index 7bbafd68..be753f8c 100644 --- a/examples/tutorial/src/ui.rs +++ b/examples/tutorial/src/ui.rs @@ -44,11 +44,12 @@ pub fn print_outcome(outcome: &Outcome) { } /// Assert an outcome matches what a scenario expects, for `--check` mode. -/// Returns `true` on match; prints a diff line and returns `false` -/// otherwise so the caller can accumulate a non-zero exit code. +/// Returns `true` on match. The mismatch line is printed only under +/// `--check`: an interactive run (the "Try it" flow, where you deliberately +/// change outcomes) stays quiet and just shows the scenario and its result. pub fn expect(outcome: &Outcome, want_allowed: bool) -> bool { let ok = outcome.is_allowed() == want_allowed; - if !ok { + if !ok && check_mode() { let want = if want_allowed { "ALLOWED" } else { "DENIED" }; println!(" \x1b[33m! CHECK FAILED\x1b[0m expected {want}, got {outcome:?}"); }