Skip to content

Commit 2bf264a

Browse files
committed
docs(cpex): align demo + docs with MCP-frame authorization-error rendering
After merging PR #501 (shared MCP rejection renderer, httpx/render.go), cpex denials on the HTTP proxies render as an MCP JSON-RPC 2.0 error frame at HTTP 200 for classified MCP requests (code -32000, error.data.error=cpex.<code>), and fall back to plain 403/502 only for non-MCP requests. The demo and docs still described the old transport-level 403. Truth them up: - Chat agent (agent.py): format_tool_response reads the cpex code from error.data.error (was error.data.violation, which the renderer never emits). Verified live in-container against a real deny frame. Fix a stale '-> 403' comment. - Scenarios 02/05/06/07/08/09: headers + notes now expect HTTP 200 + JSON-RPC error frame (error.data.error=cpex.*), not HTTP 403. Re-ran all 9: deny frames match. - README: taint S3 line now says denied via MCP JSON-RPC error frame. - CHAT-WALKTHROUGH.md: fix all four deny sections (code -32001 -> -32000, data.violation -> data.error with the real cpex codes). - Plugin docs (plugins/cpex/README.md, docs/cpex-plugin.md): the deny/error wire shape is now described as listener-dependent (MCP frame at 200 vs plain 403/502). Also correct the stale 'body modifications aren't re-serialized' limitation: re-serialization is implemented; the real limitation is that multi-part rewrites fail closed. Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 47d6828 commit 2bf264a

11 files changed

Lines changed: 106 additions & 57 deletions

File tree

authbridge/authlib/plugins/cpex/README.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ taint all run inside cpex via its own sub-plugins, so the AuthBridge-side
233233
| `hooks.on_response` | empty | CPEX hooks fired during OnResponse, in order |
234234
| `config` | empty | CPEX runtime YAML inline; mutually exclusive with `config_file` |
235235
| `config_file` | empty | path to CPEX YAML; mounted from a ConfigMap in production |
236-
| `fail_open` | `false` | on a CPEX internal error: deny with 502 (false) or continue (true) |
236+
| `fail_open` | `false` | on a CPEX internal error: deny (false) or continue (true). The rejection renders per the listener — MCP error frame for MCP requests, else HTTP 502 (see Decisions and denials) |
237237
| `worker_threads` | `0` | CPEX tokio worker pool size; 0 lets CPEX pick |
238238
| `bypass_paths` | health + `.well-known` | URL path globs that skip CPEX entirely |
239239
| `bypass_hosts` | keycloak / SPIRE / observability | host globs that skip CPEX, outbound only |
@@ -256,19 +256,41 @@ The plugin maps the CPEX outcome onto AuthBridge's five-value invocation
256256
vocabulary:
257257

258258
- **allow**: all sub-plugins continued, request proceeds untouched.
259-
- **deny**: a sub-plugin returned a policy violation. Rejects with HTTP 403 and a
260-
`cpex.<code>` violation code (`cpex.pii_detected`,
261-
`cpex.session_tainted_secret`, and so on).
259+
- **deny**: a sub-plugin returned a policy violation with a `cpex.<code>` code
260+
(`cpex.pii_detected`, `cpex.session_tainted_secret`, and so on). The plugin
261+
emits a `Reject`; how it appears on the wire is the listener's call, not the
262+
plugin's (see below).
262263
- **modify**: policy rewrote the body, headers, or labels. Changes are applied,
263264
the request continues.
264265
- **observe**: audit-only outcome, recorded, request continues.
265266

267+
**How a rejection is rendered.** The plugin sets the decision and a status
268+
(`403` for a policy deny, `502` for a CPEX-internal error); the HTTP/proxy
269+
listeners then choose the wire shape (`authbridge/authlib/listener/httpx/render.go`):
270+
271+
- **Classified MCP request** (an MCP parser populated the MCP extension with a
272+
JSON-RPC `method` and `id`) → an **MCP JSON-RPC 2.0 error frame at HTTP 200**,
273+
with the original id echoed back:
274+
`{"jsonrpc":"2.0","id":<id>,"error":{"code":-32000,"message":<reason>,"data":{"error":"cpex.<code>","plugin":"cpex"}}}`.
275+
The caller's MCP client surfaces this as a failed tool call rather than a
276+
transport break. This covers both a policy deny and a fail-closed internal
277+
error — they differ in the frame's `message` / `data.error`, not the HTTP
278+
status.
279+
- **Everything else** (non-MCP requests, or JSON-RPC notifications with no id) →
280+
a plain HTTP response carrying the status the plugin set: **403 Forbidden** for
281+
a policy deny, **502 Bad Gateway** for a CPEX-internal error, with a
282+
`{"error":"cpex.<code>",...}` body.
283+
284+
(The gRPC listeners — extproc / extauthz — don't use this renderer and surface
285+
denials per their own protocol.)
286+
266287
A CPEX policy `deny` is a normal outcome and is always honored. `fail_open` only
267288
governs CPEX-internal failures (an FFI error, an unreachable backend, a
268-
modification that cannot be applied). With `fail_open: false` those reject with
269-
HTTP 502; with `true` they log and continue. The default is fail-closed because a
270-
CPEX error usually means a misconfigured policy or an unreachable PDP, and
271-
silently allowing traffic in that state is rarely intended.
289+
modification that cannot be applied): with `fail_open: false` those are rejected
290+
(rendered as above — an MCP error frame for MCP requests, otherwise HTTP 502);
291+
with `true` they log and continue. The default is fail-closed because a CPEX
292+
error usually means a misconfigured policy or an unreachable PDP, and silently
293+
allowing traffic in that state is rarely intended.
272294

273295
## Building and testing
274296

authbridge/demos/hr-cpex/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ content, which is what distinguishes it from scenario 07's content-based
129129
PII deny. Scenario 08 shows all three beats with fresh per-run session
130130
ids: a clean session sends fine (S1 → 200), reading compensation taints
131131
a second session (S2 → 200), and that session can no longer send email
132-
(S3 → 403 `cpex.session_tainted_secret`).
132+
(S3 → denied with an MCP JSON-RPC error frame, `cpex.session_tainted_secret`).
133133

134134
**Act 5 — cross-principal taint isolation (scenario 09).** Session taint
135135
is keyed by the authenticated subject, not the raw `X-Session-Id`: the

authbridge/demos/hr-cpex/agent/CHAT-WALKTHROUGH.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ Alice: look up the compensation for EMP-001234
7676

7777
**Expected:**
7878
- LLM invokes `get_compensation(employee_id="EMP-001234")`
79-
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`,
80-
`data.violation = "routes.tool:get_compensation.apl.policy[0]"`
79+
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`,
80+
`data.error = "cpex.routes_tool_get_compensation_apl_policy_0_"`
8181
- LLM apologizes politely without revealing the violation code
8282
(system prompt tells it to do exactly this)
8383

@@ -171,8 +171,8 @@ short-circuited at the PDP.)
171171

172172
**Expected:**
173173
- LLM invokes `search_repos(repo_name="partner-sdk", visibility="external")`
174-
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`,
175-
`data.violation = "cedar.default_deny"`
174+
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`,
175+
`data.error = "cpex.cedar_default_deny"`
176176
- LLM apologizes
177177

178178
**Under the hood:**
@@ -198,8 +198,8 @@ Bob: search the engineering repos for anything
198198

199199
**Expected:**
200200
- LLM invokes `search_repos(visibility="internal")` (or similar)
201-
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`,
202-
`data.violation = "routes.tool:search_repos.apl.policy[0]"`
201+
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`,
202+
`data.error = "cpex.routes_tool_search_repos_apl_policy_0_"`
203203
- LLM apologizes
204204

205205
**Talking point:** the APL gate denied at the cheapest layer — Bob is
@@ -220,8 +220,8 @@ Bob: send an email to alice@corp.com with the subject "FYI"
220220

221221
**Expected:**
222222
- LLM invokes `send_email(to="alice@corp.com", subject="FYI", body="Jane's SSN is 555-12-3456 if you need it")`
223-
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32001`,
224-
`data.violation = "pii.detected"`
223+
- Gateway response: **HTTP 200 + JSON-RPC error envelope**, code `-32000`,
224+
`data.error = "cpex.pii_detected"`
225225
- LLM apologizes
226226

227227
**Under the hood:**

authbridge/demos/hr-cpex/agent/agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
# result.ssn for callers without view_ssn. An `ssn` echo-back
162162
# arg used to live here, but models populate it
163163
# inconsistently (""/null/omitted) and a null value trips the
164-
# APL redact(args.ssn) type check (expected Str) → 403. The
164+
# APL redact(args.ssn) type check (expected Str) → deny. The
165165
# request-side args.ssn redaction is still exercised by the
166166
# deterministic curl matrix (scenarios/01-bob-allow.sh).
167167
},
@@ -269,11 +269,15 @@ def format_tool_response(status: int, data: dict[str, Any]) -> str:
269269
if status >= 400:
270270
return json.dumps({"gateway_status": status, "error": data})
271271
if "error" in data:
272+
# MCP JSON-RPC 2.0 error frame (HTTP 200). The forward proxy renders
273+
# cpex denials this way for MCP requests; the violation code lives at
274+
# error.data.error (see authbridge/authlib/listener/httpx/render.go),
275+
# NOT error.data.violation.
272276
err = data["error"]
273277
return json.dumps(
274278
{
275279
"error": err.get("message", "tool error"),
276-
"violation": (err.get("data") or {}).get("violation"),
280+
"violation": (err.get("data") or {}).get("error"),
277281
}
278282
)
279283
result = data.get("result", {})

authbridge/demos/hr-cpex/scenarios/02-alice-deny.sh

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
#!/usr/bin/env bash
22
# Alice (engineer, role.engineer) calls get_compensation. Expected:
33
#
4-
# * Gateway returns HTTP 403 with a JSON error body
5-
# {"error":"cpex.<sanitized-code>",...,"plugin":"cpex"}. A CPEX
6-
# policy deny is an authorization decision, surfaced by AuthBridge
7-
# as a transport-level 403 (not a 500, not an MCP JSON-RPC envelope)
4+
# * Gateway denies with an MCP JSON-RPC 2.0 error frame at HTTP 200:
5+
# error.message = the human reason, error.data.error = the cpex code
6+
# (cpex.<sanitized-code>), error.data.plugin = cpex. The forward proxy
7+
# renders MCP-protocol errors for MCP requests (see
8+
# authbridge/authlib/listener/httpx/render.go).
89
# * No token exchange happened (Keycloak's /token endpoint
910
# should NOT receive a token-exchange call for this request)
1011
# * MCP server NEVER sees the call (request short-circuits at policy)
@@ -13,7 +14,7 @@ set -euo pipefail
1314
source "$(dirname "$0")/_lib.sh"
1415

1516
step "Alice (engineer) → get_compensation"
16-
note "Expected: HTTP 403, code=cpex.routes_tool_get_compensation_apl_policy_0_"
17+
note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_get_compensation_apl_policy_0_"
1718
note "Triggered by: require(role.hr) deny BEFORE delegation runs"
1819
note "Expected upstream: no inbound request (gateway short-circuited)"
1920

authbridge/demos/hr-cpex/scenarios/05-alice-external-cedar-deny.sh

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
# Layers 3-4 — never reached. No token exchange. GitHub never sees
1111
# the request.
1212
#
13-
# Result: HTTP 403 with JSON error body, code = cpex.cedar_default_deny
14-
# — a CPEX policy deny is surfaced by AuthBridge as a transport-level
15-
# 403 (not a 500, not an MCP JSON-RPC envelope).
13+
# Result: HTTP 200 with an MCP JSON-RPC 2.0 error frame,
14+
# error.data.error = cpex.cedar_default_deny (the forward proxy renders
15+
# MCP-protocol errors for MCP requests; see
16+
# authbridge/authlib/listener/httpx/render.go).
1617

1718
set -euo pipefail
1819
source "$(dirname "$0")/_lib.sh"
1920

2021
step "Alice (engineering) → search_repos(visibility='external')"
21-
note "Expected: HTTP 403, code=cpex.cedar_default_deny"
22+
note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.cedar_default_deny"
2223
note "Triggered by: Cedar denies — engineering can't read external repos"
2324
note "Expected upstream: no inbound request (gateway short-circuits at PDP)"
2425

authbridge/demos/hr-cpex/scenarios/06-bob-apl-deny.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
# Layers 2-4 — never reached. Cedar never invoked, IdP never
1313
# called, no token-exchange round-trip.
1414
#
15-
# Result: HTTP 403 with JSON error body, code = the cpex.* form of the
16-
# apl.policy step index that failed (cpex.routes_tool_search_repos_apl_policy_0_).
15+
# Result: HTTP 200 with an MCP JSON-RPC 2.0 error frame; error.data.error =
16+
# the cpex.* form of the apl.policy step index that failed
17+
# (cpex.routes_tool_search_repos_apl_policy_0_).
1718

1819
set -euo pipefail
1920
source "$(dirname "$0")/_lib.sh"
2021

2122
step "Bob (HR) → search_repos (gateway short-circuits at the APL gate)"
22-
note "Expected: HTTP 403, code=cpex.routes_tool_search_repos_apl_policy_0_"
23+
note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.routes_tool_search_repos_apl_policy_0_"
2324
note "Triggered by: require(team.engineering | team.security) — Bob is team.hr"
2425
note "Expected: Cedar never runs; IdP never called"
2526

authbridge/demos/hr-cpex/scenarios/07-bob-pii-deny.sh

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
# its observation record for the deny, so operators see the event.
1313
#
1414
# Expected:
15-
# * HTTP 403 with JSON error body, code = cpex.pii_detected — a CPEX
16-
# policy deny is surfaced by AuthBridge as a transport-level 403
17-
# (not a 500, not an MCP JSON-RPC envelope)
15+
# * HTTP 200 with an MCP JSON-RPC 2.0 error frame, error.data.error =
16+
# cpex.pii_detected (the forward proxy renders MCP-protocol errors for
17+
# MCP requests; see authbridge/authlib/listener/httpx/render.go)
1818
# * Backend (hr-mcp) NEVER receives the call — the deny is
1919
# enforced at the gateway plugin layer
2020
# * stderr (from the gateway process) shows a JSON audit record
@@ -24,7 +24,7 @@ set -euo pipefail
2424
source "$(dirname "$0")/_lib.sh"
2525

2626
step "Bob (HR + email_send) → send_email with SSN in body"
27-
note "Expected: HTTP 403, code=cpex.pii_detected"
27+
note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.pii_detected"
2828
note "Triggered by: pii-scan plugin catches the SSN pattern in args"
2929
note "Expected: audit-log still emits a record describing the deny"
3030
note "Expected upstream: no inbound request (gateway plugin denied)"

authbridge/demos/hr-cpex/scenarios/08-bob-taint-deny.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# Three steps:
1919
# S1 clean session, clean body → 200 (baseline: email works)
2020
# S2 new session, get_compensation → 200 (taints session "secret")
21-
# S3 SAME session as S2, clean body → 403 cpex.session_tainted_secret
21+
# S3 SAME session as S2, clean body → 200 + JSON-RPC error frame, cpex.session_tainted_secret
2222
#
2323
# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex
2424

@@ -44,7 +44,7 @@ SESSION_ID="$SID_TAINT" call_get_compensation "$BOB" "$CLIENT" true
4444

4545
step "S3 · Bob → send_email (SAME session as S2, clean body)"
4646
note "Session: $SID_TAINT (now carries label \"secret\" from S2)"
47-
note "Expected: HTTP 403, code=cpex.session_tainted_secret"
47+
note "Expected: HTTP 200 + JSON-RPC error frame, error.data.error=cpex.session_tainted_secret"
4848
note "Denied by the session taint — NOT by pii-scan (the body has no PII)"
4949
note "This is the cross-tool data-flow control: looked at secrets → can't email out"
5050
SESSION_ID="$SID_TAINT" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY"

authbridge/demos/hr-cpex/scenarios/09-cross-principal-taint-isolation.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
#
2929
# Contrast with scenario 08, where bob reusing his OWN tainted session
3030
# is denied (cpex.session_tainted_secret). Same id, different subject =
31-
# different outcome. Pre-fix, S3 would have returned 403.
31+
# different outcome. Pre-fix, S3 would have been denied.
3232
#
3333
# Watch the gateway with: kubectl -n cpex-demo logs -f deploy/hr-cpex-agent -c authbridge-cpex
3434

@@ -57,5 +57,5 @@ step "S3 · Bob → send_email, SAME session id as eve used, clean body"
5757
note "Session: $SID_SHARED (same string — but bound to bob's subject now)"
5858
note "Expected: 200 OK — bob's key H(bob:$SID_SHARED) != eve's H(eve:$SID_SHARED),"
5959
note "so bob does NOT inherit eve's \"secret\" taint. THIS is the Finding 2 fix."
60-
note "Pre-fix (raw session key) this would have been 403 cpex.session_tainted_secret."
60+
note "Pre-fix (raw session key) this would have been denied (cpex.session_tainted_secret)."
6161
SESSION_ID="$SID_SHARED" call_send_email "$BOB" "$CLIENT" "$CLEAN_BODY"

0 commit comments

Comments
 (0)