Module 7: Information flow (Tainting)#

You are in the CPEX tutorial. This module needs the IdP.

Goal: carry security state across a session so a later request can be denied because of what an earlier one did. This is how CPEX stops write-down.

The problem#

Most authorization systems judge each request alone. That misses a whole class of leaks: a caller reads sensitive data, then sends it out through a channel that looks harmless on its own. You want the second call blocked because of the first, even though nothing about the second call is suspicious in isolation.

Build it#

One route taints the session; another refuses when the taint is present. From policies/m07.yaml:

routes:
  - tool: get_compensation
    authentication: [keycloak]
    authorization:
      pre_invocation:
        - "require(role.hr)"
        - "taint(secret, session)"        # mark the session as having read secret data

  - tool: send_email
    authentication: [keycloak]
    authorization:
      pre_invocation:
        - "require(authenticated)"
        - "security.labels contains \"secret\": deny('write-down blocked: this session read secret data', 'session_tainted')"

taint(secret, session) records the secret label on the session, and it persists in the session store. send_email reads security.labels, the accumulated session state, not just this request’s arguments. The label outlives the request that set it.

Run it#

cargo run -p cpex-tutorial --example m07_tainting
▸ alice, fresh session → send_email (nothing tainted yet)
  ✓ ALLOWED  {"sent":true, ...}

▸ alice, session-hr-work → get_compensation (taints session 'secret')
  ✓ ALLOWED  { ... }

▸ alice, session-hr-work → send_email (write-down blocked)
  ✗ DENIED   [session_tainted] write-down blocked: this session read secret data

The same send_email call is allowed in a clean session and denied in the session that read compensation. The email’s content is irrelevant. The session’s history blocks it.

Try it#

  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:
      global:
        apl:
          session_store:
            kind: valkey
            endpoint: localhost:6379
    Re-run: the taint now lives in Valkey and survives a process restart.

Checkpoint#

Why is the email denied when its own content is harmless?
The denial is based on session state, not the email. `get_compensation` tainted the session with `secret`, and `send_email` refuses whenever that label is present. Information flow is tracked across the whole session.
What ties the two calls together?
A shared session id plus the same authenticated subject. That pair keys the session store, so the taint set by the first call is visible to the second.

Go deeper#

Next#

Module 8: Human in the loop: suspend an operation until a human approves it.