Module 1: Hello, enforcement#

You are in the CPEX tutorial. No IdP needed for this module.

Goal: stand up the smallest possible CPEX enforcement point and see a route allow one call and deny another, with no application logic making the decision.

The problem#

You have tools an agent can call. Some should be gated, some open. You do not want that decision scattered through handler code, where it drifts and is hard to audit. You want it in one place, declarative, at the boundary.

Build it#

The host is three lines plus loading a policy. From examples/tutorial/examples/m01_hello.rs:

let mgr = Arc::new(PluginManager::default());
cpex::install_builtins(&mgr);               // register the bundled plugins + APL visitor
mgr.load_config_yaml(POLICY).unwrap();      // load policies/m01.yaml
mgr.initialize().await.unwrap();

The policy (policies/m01.yaml) defines two routes:

routes:
  - tool: get_compensation
    authorization:
      pre_invocation:
        - "require(authenticated)"    # denies an anonymous caller
  - tool: search_repos
    authorization:
      pre_invocation: []              # no rule, so open

Each call goes through mediate(), the harness wrapper around the loop a host owns: resolve identity, run policy, call the backend, run policy on the result. It is harness code, not a CPEX API. Module 9 opens it up.

Run it#

cargo run -p cpex-tutorial --example m01_hello
▸ anonymous → get_compensation (route requires authentication)
  ✗ DENIED   [routes.tool:get_compensation.apl.pre_invocation[0]] access denied

▸ anonymous → search_repos (route has no rule)
  ✓ ALLOWED  {"visibility":"public","repositories":[{"name":"brand-site","visibility":"public"}]}

Same anonymous caller, same host code. The route decided the outcome, and the denial names the exact rule that failed.

Try it#

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

Checkpoint#

Why did get_compensation deny when the Rust code never checked anything?
The route's `require(authenticated)` predicate failed, because the caller is anonymous. The decision lives entirely in policy. `mediate()` just reports what the route decided.
What makes search_repos allow?
It has no denying rule. A route with an empty or absent `pre_invocation` is open by default. CPEX blocks only what policy tells it to.

Go deeper#

Next#

Module 2: Who’s calling?: give callers a real identity so require(role.hr) has something to check. Start the IdP first.