Skip to content

feat(cel): bind request.body_json + on_match.deny (ADR-0030 prereqs)#72

Open
ndreno wants to merge 2 commits intomainfrom
feat/cel-body-json
Open

feat(cel): bind request.body_json + on_match.deny (ADR-0030 prereqs)#72
ndreno wants to merge 2 commits intomainfrom
feat/cel-body-json

Conversation

@ndreno
Copy link
Copy Markdown
Contributor

@ndreno ndreno commented Apr 30, 2026

Summary

ADR-0030 §0's worked example for AI consumer policy looks like:

- name: cel
  config:
    expression: "request.body_json.model.startsWith('gpt-4o') && request.claims.tier != 'premium'"
    on_match:
      deny:
        status: 403
        code: "model_not_permitted"

Two pieces of the current cel plugin made that example silently broken:

  1. request.body is exposed only as a raw string — request.body_json.model couldn't evaluate.
  2. OnMatch only knew set_context and accepted unknown fields, so serde silently dropped the deny block. The plugin then ran in routing mode with no context keys set: true continued, false continued, 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_json binding

  • plugins/cel/src/lib.rsbuild_context now binds request.body_json alongside the existing request.body string when the inbound content-type is application/json or any application/*+json vendor type. Parameters (; charset=utf-8) are stripped before matching. New parse_body_json helper, and host::log_warn (mirrors the pattern in ai-proxy's host stub) for parse-failure logging.

on_match.deny

  • plugins/cel/src/lib.rs — extends OnMatch with an optional deny: DenyAction { status, code, message? }. When the expression matches, deny wins over set_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 configured code becomes both the URN suffix on the response type and a code field on the problem+json body, matching the error.type = "model_not_permitted" convention ai-proxy will use in later ADR-0030 PRs.
  • Both OnMatch and DenyAction now deny_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.deny block added with status (integer, 4xx-bounded, default 403), code (pattern: ^[a-z][a-z0-9_]*$), message (optional). additionalProperties: false everywhere.
  • 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_json rather than auto-overloading body — purely additive, never changes semantics for existing expressions evaluating request.body == ''.
  • Parse-failure mode — log a warning, bind an empty map, never short-circuit. A CEL plugin that 500s on every garbled body would let an attacker take down every downstream policy with one bad byte.
  • deny wins over set_context — a denied request shouldn't also have its context mutated; downstream plugins would see partial state for a request that got rejected.
  • Status clamped to 4xxcel denies are policy decisions; surfacing them as 5xx would mask operator decisions as server faults.
  • deny_unknown_fields on OnMatch and DenyAction — 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 with on_match.deny, vendor +json, charset-suffixed content-type, non-JSON, malformed JSON (warning + empty map), empty body
    • on_match.deny: default status 403, custom 4xx status, 5xx → 403 fallback, message override, message fallback to code, precedence over set_context, no-op on false, unknown-field rejection
  • cargo build --target wasm32-unknown-unknown --release from plugins/cel/ — WASM artifact builds clean
  • cargo build --workspace — workspace compiles
  • cargo test --workspace --exclude barbacane-test — all green
  • cargo clippy --lib --bins — zero warnings on production code
  • cargo deny check advisoriesadvisories ok
  • cargo fmt --all -- --check — clean
  • node docs/rulesets/generate.mjs + bash docs/rulesets/tests/run-tests.sh14 passed, 0 failed
  • CI green

ndreno added 2 commits April 30, 2026 18:08
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.
@ndreno ndreno changed the title feat(cel): bind request.body_json for JSON bodies (ADR-0030 prereq) feat(cel): bind request.body_json + on_match.deny (ADR-0030 prereqs) Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant