feat(cel): bind request.body_json + on_match.deny (ADR-0030 prereqs)#72
Open
feat(cel): bind request.body_json + on_match.deny (ADR-0030 prereqs)#72
Conversation
ADR-0030 §0 calls for a CEL plugin extension so AI consumer-policy
expressions like `request.body_json.model.startsWith('gpt-4o')` work
out of the box. Today the plugin only exposes `request.body` as a
raw string, which makes JSON-field access unusable in policies.
This adds `request.body_json` alongside the existing string `body`
binding when the inbound `content-type` is `application/json` or any
`application/*+json` vendor type (parameters like `; charset=utf-8`
are stripped). Non-JSON content-types and parse failures yield an
empty CEL map — `has(request.body_json.x)` evaluates cleanly.
Parse failures log a warning but never short-circuit the request: a
CEL plugin that returned 500 on every garbled body would let an
attacker take down every downstream policy with one bad byte.
Naming choice: `body_json` rather than auto-overloading `body` keeps
the change purely additive and never alters semantics for existing
expressions evaluating `request.body == ''`.
Tests cover field access, the AI consumer-policy example, vendor
+json content-types, charset-suffixed content-types, non-JSON
content-types, malformed JSON bodies (warning + empty map), and
empty request bodies.
Cargo.lock unrelated bump from a stale 0.6.0 → 0.6.3 alignment with
the workspace SDK/macros versions.
ADR-0030 §0's worked example uses `on_match.deny: { status, code }` to
reject a matched request. Today the cel plugin's `OnMatch` only knows
`set_context`, and serde silently dropped unknown fields — which meant
the ADR example would parse but enforce nothing. Quietly broken policy.
This adds the missing `deny` action under `on_match`:
on_match:
deny:
status: 403 # optional; defaults to 403, must be 4xx
code: model_not_permitted
message: "..." # optional; falls back to `code`
The configured `code` is exposed both as the URN suffix on the response
`type` field and as a `code` field on the body, matching the
`error.type = "model_not_permitted"` convention `ai-proxy` will use in
later ADR-0030 PRs. Status is clamped into the 4xx range — denying as
5xx would mask a policy decision as a server fault.
When both `set_context` and `deny` are configured for the same
`on_match`, `deny` wins on a match and context is not written. A denied
request shouldn't also leak partial state to downstream plugins.
Both `OnMatch` and the new `DenyAction` now `deny_unknown_fields`, so
operator typos surface at config-load time instead of being silently
dropped — the original cause of the bug this PR fixes. The pre-existing
access-control workaround test is replaced with the ADR's exact
`on_match.deny` form (per-tier model gating). 8 new tests cover status
default, custom 4xx status, 5xx → 403 fallback, message override,
message fallback, precedence over `set_context`, no-op on `false`, and
unknown-field rejection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ADR-0030 §0's worked example for AI consumer policy looks like:
Two pieces of the current
celplugin made that example silently broken:request.bodyis exposed only as a raw string —request.body_json.modelcouldn't evaluate.OnMatchonly knewset_contextand accepted unknown fields, so serde silently dropped thedenyblock. The plugin then ran in routing mode with no context keys set:truecontinued,falsecontinued, no enforcement. Quietly broken policy.This is PR-0 in the ADR-0030 implementation plan — independent of all the AI-proxy work that follows, but a prerequisite for the AI consumer-policy examples in the shipped spec fragment (PR-6).
What changed
request.body_jsonbindingplugins/cel/src/lib.rs—build_contextnow bindsrequest.body_jsonalongside the existingrequest.bodystring when the inboundcontent-typeisapplication/jsonor anyapplication/*+jsonvendor type. Parameters (; charset=utf-8) are stripped before matching. Newparse_body_jsonhelper, andhost::log_warn(mirrors the pattern inai-proxy's host stub) for parse-failure logging.on_match.denyplugins/cel/src/lib.rs— extendsOnMatchwith an optionaldeny: DenyAction { status, code, message? }. When the expression matches,denywins overset_context: a denied request is not also written to context. Status defaults to 403 and is clamped into the 4xx range — denying as 5xx would mask a policy decision as a server fault. The configuredcodebecomes both the URN suffix on the responsetypeand acodefield on the problem+json body, matching theerror.type = "model_not_permitted"conventionai-proxywill use in later ADR-0030 PRs.OnMatchandDenyActionnowdeny_unknown_fields— operator typos surface at config-load time instead of being silently dropped, the underlying cause of the original ADR-bug.plugins/cel/config-schema.json— schema description updated;on_match.denyblock added withstatus(integer, 4xx-bounded, default 403),code(pattern: ^[a-z][a-z0-9_]*$),message(optional).additionalProperties: falseeverywhere.docs/rulesets/functions/*— vacuum validators regenerated; ruleset tests pass (14 passed, 0 failed).Other
CHANGELOG.md— two entries under## [Unreleased]→### Added.README.md— test-count badge bumped (777 → 792 plugin tests).plugins/cel/Cargo.lock— unrelated stale-pin refresh from 0.6.0 → 0.6.3 of workspace SDK/macros.Design choices
body_jsonrather than auto-overloadingbody— purely additive, never changes semantics for existing expressions evaluatingrequest.body == ''.denywins overset_context— a denied request shouldn't also have its context mutated; downstream plugins would see partial state for a request that got rejected.celdenies are policy decisions; surfacing them as 5xx would mask operator decisions as server faults.deny_unknown_fieldsonOnMatchandDenyAction— closes the silent-drop loophole that made the ADR's own example look like it would work when it wouldn't.Test plan
cargo test --manifest-path plugins/cel/Cargo.toml— 54 unit tests pass (was 39 before this PR; +15 across both additions):body_json: field access, AI consumer-policy example withon_match.deny, vendor+json, charset-suffixed content-type, non-JSON, malformed JSON (warning + empty map), empty bodyon_match.deny: default status 403, custom 4xx status, 5xx → 403 fallback, message override, message fallback tocode, precedence overset_context, no-op onfalse, unknown-field rejectioncargo build --target wasm32-unknown-unknown --releasefromplugins/cel/— WASM artifact builds cleancargo build --workspace— workspace compilescargo test --workspace --exclude barbacane-test— all greencargo clippy --lib --bins— zero warnings on production codecargo deny check advisories—advisories okcargo fmt --all -- --check— cleannode docs/rulesets/generate.mjs+bash docs/rulesets/tests/run-tests.sh—14 passed, 0 failed