From 2d32c4f679ea053451e3bb1f432732c0bc520c92 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Thu, 30 Apr 2026 18:08:46 +0200 Subject: [PATCH 1/2] feat(cel): bind request.body_json for application/json content-types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + README.md | 2 +- plugins/cel/Cargo.lock | 4 +- plugins/cel/src/lib.rs | 179 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ee59d..c4ecb2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **compiler**: `specs` field in `barbacane.yaml` — point to a folder (e.g., `specs: ./specs/`) and all `*.yaml`/`*.json` files are discovered automatically. Used by `barbacane dev` for zero-config operation and as a fallback for `barbacane compile` when `--spec` is omitted. - **cli**: `barbacane compile` now discovers specs from the manifest's `specs` folder when `--spec` is not provided — `barbacane compile -m barbacane.yaml -o api.bca` works with zero spec args. - **cli**: `barbacane init` now scaffolds a `specs/` directory and places the generated spec in `specs/api.yaml` with `specs: ./specs/` in the manifest. +- **plugin**: `cel` now binds `request.body_json` in addition to the existing `request.body` string when the inbound `content-type` is `application/json` or any `application/*+json` vendor type. Enables consumer-policy expressions like `request.body_json.model.startsWith('gpt-4o')` without writing string-matching CEL. Empty map on non-JSON content-types and on parse failures (warning logged on failure; never short-circuits the request — a CEL plugin that 500s on every garbled body would let an attacker take down every downstream policy with one bad byte). Prereq for the AI consumer-policy examples in ADR-0030. #### AI Gateway middlewares (ADR-0024) - **`ai-prompt-guard` middleware plugin**: validates LLM chat-completion requests before dispatch — named profiles carry `max_messages`, `max_message_length`, regex `blocked_patterns`, and managed `system_template` with `{var}` substitution. Short-circuits with 400 + RFC 9457 problem+json on violation. diff --git a/README.md b/README.md index 311d797..6c99723 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ CI Documentation Unit Tests - Plugin Tests + Plugin Tests Integration Tests CLI Tests UI Tests diff --git a/plugins/cel/Cargo.lock b/plugins/cel/Cargo.lock index af33db7..8888b9d 100644 --- a/plugins/cel/Cargo.lock +++ b/plugins/cel/Cargo.lock @@ -31,7 +31,7 @@ dependencies = [ [[package]] name = "barbacane-plugin-macros" -version = "0.6.0" +version = "0.6.3" dependencies = [ "quote", "syn", @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "barbacane-plugin-sdk" -version = "0.6.0" +version = "0.6.3" dependencies = [ "barbacane-plugin-macros", "base64", diff --git a/plugins/cel/src/lib.rs b/plugins/cel/src/lib.rs index 5144c39..c7f427f 100644 --- a/plugins/cel/src/lib.rs +++ b/plugins/cel/src/lib.rs @@ -125,6 +125,7 @@ impl CelPolicy { "body".to_string(), str_val(req.body_str().unwrap_or("")), ); + request_map.insert("body_json".to_string(), parse_body_json(req)); request_map.insert("client_ip".to_string(), str_val(&req.client_ip)); // Headers as a map @@ -239,6 +240,44 @@ fn btree_to_cel_map(map: &BTreeMap) -> cel::Value { cel_map.into() } +/// Empty CEL map. Returned as the `request.body_json` value when the body +/// can't be parsed as JSON — keeps `has(request.body_json.x)` semantics clean. +fn empty_cel_map() -> cel::Value { + HashMap::::new().into() +} + +/// Parse the request body as JSON when the inbound `content-type` advertises it +/// (`application/json` or any `application/*+json` vendor type, ignoring `;`-suffixed +/// parameters). Returns an empty map for non-JSON content-types and for malformed +/// bodies; the latter logs a warning so operators see policy mis-applies, but never +/// short-circuits the request — a CEL plugin that rejected on every malformed JSON +/// would let an attacker take down every downstream policy by sending one bad byte. +fn parse_body_json(req: &Request) -> cel::Value { + let content_type = match req.headers.get("content-type") { + Some(v) => v.split(';').next().unwrap_or("").trim().to_ascii_lowercase(), + None => return empty_cel_map(), + }; + let is_json = content_type == "application/json" + || (content_type.starts_with("application/") && content_type.ends_with("+json")); + if !is_json { + return empty_cel_map(); + } + let body = match req.body_str() { + Some(s) if !s.is_empty() => s, + _ => return empty_cel_map(), + }; + match serde_json::from_str::(body) { + Ok(v) => json_to_cel(v), + Err(e) => { + host::log_warn(&format!( + "cel: request body advertised {} but could not be parsed as JSON: {}", + content_type, e + )); + empty_cel_map() + } + } +} + /// Convert a serde_json::Value to a CEL value. fn json_to_cel(value: serde_json::Value) -> cel::Value { match value { @@ -304,6 +343,14 @@ mod host { ); } } + + pub fn log_warn(msg: &str) { + #[link(wasm_import_module = "barbacane")] + extern "C" { + fn host_log(level: i32, msg_ptr: i32, msg_len: i32); + } + unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) } + } } #[cfg(not(target_arch = "wasm32"))] @@ -313,6 +360,7 @@ mod host { thread_local! { static CONTEXT: RefCell> = const { RefCell::new(BTreeMap::new()) }; + static WARNINGS: RefCell> = const { RefCell::new(Vec::new()) }; } pub fn context_set(key: &str, value: &str) { @@ -321,6 +369,10 @@ mod host { }); } + pub fn log_warn(msg: &str) { + WARNINGS.with(|w| w.borrow_mut().push(msg.to_string())); + } + #[cfg(test)] pub fn get_context() -> BTreeMap { CONTEXT.with(|ctx| ctx.borrow().clone()) @@ -330,6 +382,11 @@ mod host { pub fn reset_context() { CONTEXT.with(|ctx| ctx.borrow_mut().clear()); } + + #[cfg(test)] + pub fn take_warnings() -> Vec { + WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut())) + } } // --------------------------------------------------------------------------- @@ -647,6 +704,128 @@ mod tests { } } + // --- request.body_json access (ADR-0030) --- + + #[test] + fn eval_body_json_field_access() { + let mut config = create_config("request.body_json.foo == 'bar'"); + let mut req = create_request(); + req.body = Some(br#"{"foo":"bar"}"#.to_vec()); + match config.on_request(req) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + } + + #[test] + fn eval_body_json_ai_consumer_policy_example() { + // The motivating ADR-0030 example: per-tier model gating. CEL access-control + // mode treats `true` as allow / `false` as deny, so the policy is the + // negation of "non-premium asks for gpt-4o". + let mut config = create_config( + "!(request.body_json.model.startsWith('gpt-4o') && request.claims.tier != 'premium')", + ); + + let mut req_blocked = create_request(); + req_blocked.headers.insert( + "x-auth-claims".to_string(), + r#"{"tier":"free"}"#.to_string(), + ); + req_blocked.body = Some(br#"{"model":"gpt-4o-mini"}"#.to_vec()); + match config.on_request(req_blocked) { + Action::Continue(_) => panic!("expected 403 for free tier on gpt-4o-mini"), + Action::ShortCircuit(resp) => assert_eq!(resp.status, 403), + } + + let mut req_allowed = create_request(); + req_allowed.headers.insert( + "x-auth-claims".to_string(), + r#"{"tier":"premium"}"#.to_string(), + ); + req_allowed.body = Some(br#"{"model":"gpt-4o"}"#.to_vec()); + match config.on_request(req_allowed) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + } + + #[test] + fn eval_body_json_vendor_plus_json_content_type() { + let mut config = create_config("request.body_json.kind == 'event'"); + let mut req = create_request(); + req.headers.insert( + "content-type".to_string(), + "application/vnd.api+json".to_string(), + ); + req.body = Some(br#"{"kind":"event"}"#.to_vec()); + match config.on_request(req) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + } + + #[test] + fn eval_body_json_content_type_with_charset_param() { + let mut config = create_config("request.body_json.foo == 'bar'"); + let mut req = create_request(); + req.headers.insert( + "content-type".to_string(), + "application/json; charset=utf-8".to_string(), + ); + req.body = Some(br#"{"foo":"bar"}"#.to_vec()); + match config.on_request(req) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + } + + #[test] + fn eval_body_json_non_json_content_type_yields_empty_map() { + // text/plain → body_json is an empty map → has() returns false, not an error. + let mut config = create_config("!has(request.body_json.foo)"); + let mut req = create_request(); + req.headers + .insert("content-type".to_string(), "text/plain".to_string()); + req.body = Some(b"this is not json".to_vec()); + match config.on_request(req) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + } + + #[test] + fn eval_body_json_malformed_body_logs_warning_and_yields_empty_map() { + // Malformed JSON with a JSON content-type must NOT short-circuit the request — + // a CEL plugin that 500s on every garbled body would let an attacker take down + // every downstream policy with one bad byte. Instead: empty map + log warning. + let _ = host::take_warnings(); // clear any prior test's warnings + + let mut config = create_config("!has(request.body_json.foo)"); + let mut req = create_request(); + req.body = Some(b"not-actually-json{".to_vec()); + match config.on_request(req) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + + let warnings = host::take_warnings(); + assert!( + warnings.iter().any(|w| w.contains("could not be parsed as JSON")), + "expected a parse-failure warning, got {:?}", + warnings + ); + } + + #[test] + fn eval_body_json_empty_body_yields_empty_map() { + let mut config = create_config("!has(request.body_json.foo)"); + let req = create_request(); // body is None + match config.on_request(req) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status), + } + } + // --- Error handling --- #[test] From 9dd50a62e271851ba88b7cf3bc68a9bd00b75068 Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Thu, 30 Apr 2026 18:26:07 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(cel):=20on=5Fmatch.deny=20=E2=80=94=20?= =?UTF-8?q?match-and-reject=20with=20status/code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + README.md | 2 +- plugins/cel/config-schema.json | 28 +++- plugins/cel/src/lib.rs | 283 +++++++++++++++++++++++++++++++-- 4 files changed, 301 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ecb2f..e43327c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cli**: `barbacane compile` now discovers specs from the manifest's `specs` folder when `--spec` is not provided — `barbacane compile -m barbacane.yaml -o api.bca` works with zero spec args. - **cli**: `barbacane init` now scaffolds a `specs/` directory and places the generated spec in `specs/api.yaml` with `specs: ./specs/` in the manifest. - **plugin**: `cel` now binds `request.body_json` in addition to the existing `request.body` string when the inbound `content-type` is `application/json` or any `application/*+json` vendor type. Enables consumer-policy expressions like `request.body_json.model.startsWith('gpt-4o')` without writing string-matching CEL. Empty map on non-JSON content-types and on parse failures (warning logged on failure; never short-circuits the request — a CEL plugin that 500s on every garbled body would let an attacker take down every downstream policy with one bad byte). Prereq for the AI consumer-policy examples in ADR-0030. +- **plugin**: `cel` `on_match.deny: { status, code, message? }` — reject the request with a configurable problem+json status and code when the expression matches. The `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 used by `ai-proxy`. Status defaults to 403 and 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. `OnMatch` and `DenyAction` now `deny_unknown_fields`, so operator typos surface at config-load time instead of being silently dropped. #### AI Gateway middlewares (ADR-0024) - **`ai-prompt-guard` middleware plugin**: validates LLM chat-completion requests before dispatch — named profiles carry `max_messages`, `max_message_length`, regex `blocked_patterns`, and managed `system_template` with `{var}` substitution. Short-circuits with 400 + RFC 9457 problem+json on violation. diff --git a/README.md b/README.md index 6c99723..f754839 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ CI Documentation Unit Tests - Plugin Tests + Plugin Tests Integration Tests CLI Tests UI Tests diff --git a/plugins/cel/config-schema.json b/plugins/cel/config-schema.json index 9359dd3..4c150ed 100644 --- a/plugins/cel/config-schema.json +++ b/plugins/cel/config-schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:barbacane:plugin:cel:config", "title": "CEL Policy Evaluation Middleware Config", - "description": "Configuration for the CEL policy evaluation middleware plugin. Evaluates inline CEL expressions against request context.\n\nTwo modes:\n- **Access-control** (default, no `on_match`): `true` → continue, `false` → 403 Forbidden.\n- **Routing** (`on_match` present): `true` → write context keys and continue, `false` → continue unchanged (no 403). Used with the `ai-proxy` dispatcher to implement policy-driven model routing.", + "description": "Configuration for the CEL policy evaluation middleware plugin. Evaluates inline CEL expressions against request context.\n\nTwo modes:\n- **Access-control** (default, no `on_match`): `true` → continue, `false` → 403 Forbidden.\n- **`on_match`** (present): on `true`, take the configured actions — write context keys (`set_context`) and/or reject with a configured status + code (`deny`). On `false`, continue unchanged (no 403). When both `set_context` and `deny` are present, `deny` wins on a match — a denied request is not also written to context.", "type": "object", "required": ["expression"], "additionalProperties": false, @@ -18,7 +18,7 @@ }, "on_match": { "type": "object", - "description": "When present, switches from access-control mode to routing mode. On a `true` result, the configured context keys are written and the request continues. On a `false` result, the request continues unchanged (no 403). Stack multiple `cel` instances to implement multiple routing rules.", + "description": "Actions to take when the expression evaluates to `true`. On a `false` result the request continues unchanged (no 403). Stack multiple `cel` instances to compose multiple match rules. When both `set_context` and `deny` are configured, a matched request is denied without context being written.", "additionalProperties": false, "properties": { "set_context": { @@ -27,6 +27,30 @@ "additionalProperties": { "type": "string" } + }, + "deny": { + "type": "object", + "description": "Reject the request with the given problem+json status and code when the expression is true. The `code` becomes the URN suffix on the `type` field and is exposed as the `code` field on the response body — the convention used by `ai-proxy` for `model_not_permitted` and similar.", + "required": ["code"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "description": "HTTP status code. Must be 4xx — denying as 5xx would mask a policy decision as a server fault. Out-of-range values fall back to 403.", + "minimum": 400, + "maximum": 499, + "default": 403 + }, + "code": { + "type": "string", + "description": "Machine-readable error code, snake_case by convention. Becomes the URN suffix on the response `type` field.", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "message": { + "type": "string", + "description": "Human-readable detail message. Falls back to `code` when omitted." + } + } } } } diff --git a/plugins/cel/src/lib.rs b/plugins/cel/src/lib.rs index c7f427f..c73c795 100644 --- a/plugins/cel/src/lib.rs +++ b/plugins/cel/src/lib.rs @@ -11,13 +11,42 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; -/// Context keys to set when the expression matches. -/// Used by `on_match` to route requests to named AI targets or set other context. +/// Actions to take when the expression matches (evaluates to `true`). +/// +/// Either or both fields can be present. When both are set, `deny` wins — +/// a denied request shouldn't also have its context mutated. #[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] struct OnMatch { /// Context key-value pairs to write via `host_context_set` when expression is true. #[serde(default)] set_context: BTreeMap, + /// Reject the request with the configured status / code when expression is true. + #[serde(default)] + deny: Option, +} + +/// Configurable deny response for `on_match.deny`. The error code is embedded +/// into a `urn:barbacane:error:` problem+json type and exposed alongside +/// `status` and `detail` so clients can introspect the policy decision. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DenyAction { + /// HTTP status code. Defaults to 403; must be 4xx (5xx would mask a policy + /// decision as a server fault). + #[serde(default = "default_deny_status")] + status: u16, + /// Machine-readable error code, snake_case. Becomes the URN suffix and the + /// `code` field on the response body — the convention used by `ai-proxy` + /// for `model_not_permitted` and similar. + code: String, + /// Human-readable detail message. Falls back to `code` when omitted. + #[serde(default)] + message: Option, +} + +fn default_deny_status() -> u16 { + 403 } /// CEL policy evaluation middleware configuration. @@ -71,6 +100,11 @@ impl CelPolicy { match program.execute(&context) { Ok(cel::Value::Bool(true)) => { if let Some(on_match) = &self.on_match { + if let Some(deny) = &on_match.deny { + // Deny wins over set_context — a denied request shouldn't + // also have its context mutated. + return Action::ShortCircuit(self.deny_action_response(deny)); + } for (key, value) in &on_match.set_context { host::context_set(key, value); } @@ -179,6 +213,43 @@ impl CelPolicy { } } + /// problem+json response for `on_match.deny`. The configured `code` becomes + /// the URN suffix and the `code` field on the body. Status defaults to 403 + /// and is clamped into the 4xx range — a `cel` policy denial that returned + /// 5xx would mask an operator decision as a server fault. + fn deny_action_response(&self, action: &DenyAction) -> Response { + let status = if (400..500).contains(&action.status) { + action.status + } else { + 403 + }; + let title = http_reason_phrase(status); + let detail = action + .message + .clone() + .unwrap_or_else(|| action.code.clone()); + + let mut headers = BTreeMap::new(); + headers.insert( + "content-type".to_string(), + "application/problem+json".to_string(), + ); + + let body = serde_json::json!({ + "type": format!("urn:barbacane:error:{}", action.code), + "title": title, + "status": status, + "code": action.code, + "detail": detail, + }); + + Response { + status, + headers, + body: Some(body.to_string().into_bytes()), + } + } + /// 500 Internal Server Error for CEL configuration errors (bad expression). fn config_error_response(&self, detail: &str) -> Response { let mut headers = BTreeMap::new(); @@ -307,6 +378,23 @@ fn json_to_cel(value: serde_json::Value) -> cel::Value { } } +/// HTTP reason-phrase for the small set of 4xx codes a `cel` deny is likely +/// to use (RFC 9110 §15.5). Falls back to "Forbidden" — denying access is the +/// dominant case and the reason-phrase is not load-bearing for clients anyway. +fn http_reason_phrase(status: u16) -> &'static str { + match status { + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 409 => "Conflict", + 422 => "Unprocessable Entity", + 429 => "Too Many Requests", + _ => "Forbidden", + } +} + /// Get a human-readable type name for a CEL value (for error messages). fn value_type_name(value: &cel::Value) -> &'static str { match value { @@ -411,7 +499,10 @@ mod tests { expression: expression.to_string(), deny_message: default_deny_message(), compiled: None, - on_match: Some(OnMatch { set_context }), + on_match: Some(OnMatch { + set_context, + deny: None, + }), } } @@ -719,13 +810,19 @@ mod tests { #[test] fn eval_body_json_ai_consumer_policy_example() { - // The motivating ADR-0030 example: per-tier model gating. CEL access-control - // mode treats `true` as allow / `false` as deny, so the policy is the - // negation of "non-premium asks for gpt-4o". - let mut config = create_config( - "!(request.body_json.model.startsWith('gpt-4o') && request.claims.tier != 'premium')", - ); + // The motivating ADR-0030 example: per-tier model gating using `on_match.deny`. + let json = r#"{ + "expression": "request.body_json.model.startsWith('gpt-4o') && request.claims.tier != 'premium'", + "on_match": { + "deny": { + "status": 403, + "code": "model_not_permitted" + } + } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + // Free tier asking for gpt-4o-mini → expression matches → 403 model_not_permitted. let mut req_blocked = create_request(); req_blocked.headers.insert( "x-auth-claims".to_string(), @@ -734,9 +831,17 @@ mod tests { req_blocked.body = Some(br#"{"model":"gpt-4o-mini"}"#.to_vec()); match config.on_request(req_blocked) { Action::Continue(_) => panic!("expected 403 for free tier on gpt-4o-mini"), - Action::ShortCircuit(resp) => assert_eq!(resp.status, 403), + Action::ShortCircuit(resp) => { + assert_eq!(resp.status, 403); + let body: serde_json::Value = + serde_json::from_slice(&resp.body.unwrap()).expect("problem+json"); + assert_eq!(body["code"], "model_not_permitted"); + assert_eq!(body["type"], "urn:barbacane:error:model_not_permitted"); + assert_eq!(body["status"], 403); + } } + // Premium tier asking for gpt-4o → expression false → continue. let mut req_allowed = create_request(); req_allowed.headers.insert( "x-auth-claims".to_string(), @@ -749,6 +854,164 @@ mod tests { } } + #[test] + fn on_match_deny_default_status_is_403() { + // `status` omitted → defaults to 403. + let json = r#"{ + "expression": "true", + "on_match": { "deny": { "code": "model_not_permitted" } } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + match config.on_request(create_request()) { + Action::Continue(_) => panic!("expected deny"), + Action::ShortCircuit(resp) => assert_eq!(resp.status, 403), + } + } + + #[test] + fn on_match_deny_honors_custom_status() { + // Operator can pick a non-403 status (e.g. 429 for budget exhaustion). + let json = r#"{ + "expression": "true", + "on_match": { "deny": { "status": 429, "code": "budget_exhausted" } } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + match config.on_request(create_request()) { + Action::Continue(_) => panic!("expected deny"), + Action::ShortCircuit(resp) => { + assert_eq!(resp.status, 429); + let body: serde_json::Value = + serde_json::from_slice(&resp.body.unwrap()).expect("problem+json"); + assert_eq!(body["title"], "Too Many Requests"); + assert_eq!(body["code"], "budget_exhausted"); + } + } + } + + #[test] + fn on_match_deny_falls_back_to_403_for_non_4xx_status() { + // 500 would mask a policy decision as a server fault — clamp to 403. + let mut config = CelPolicy { + expression: "true".to_string(), + deny_message: default_deny_message(), + compiled: None, + on_match: Some(OnMatch { + set_context: BTreeMap::new(), + deny: Some(DenyAction { + status: 500, + code: "oops".to_string(), + message: None, + }), + }), + }; + match config.on_request(create_request()) { + Action::Continue(_) => panic!("expected deny"), + Action::ShortCircuit(resp) => assert_eq!(resp.status, 403), + } + } + + #[test] + fn on_match_deny_uses_message_when_provided() { + let json = r#"{ + "expression": "true", + "on_match": { + "deny": { + "code": "model_not_permitted", + "message": "gpt-4o is reserved for premium tier" + } + } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + match config.on_request(create_request()) { + Action::Continue(_) => panic!("expected deny"), + Action::ShortCircuit(resp) => { + let body: serde_json::Value = + serde_json::from_slice(&resp.body.unwrap()).expect("problem+json"); + assert_eq!(body["detail"], "gpt-4o is reserved for premium tier"); + } + } + } + + #[test] + fn on_match_deny_falls_back_to_code_for_detail_when_message_omitted() { + let json = r#"{ + "expression": "true", + "on_match": { "deny": { "code": "model_not_permitted" } } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + match config.on_request(create_request()) { + Action::Continue(_) => panic!("expected deny"), + Action::ShortCircuit(resp) => { + let body: serde_json::Value = + serde_json::from_slice(&resp.body.unwrap()).expect("problem+json"); + assert_eq!(body["detail"], "model_not_permitted"); + } + } + } + + #[test] + fn on_match_deny_wins_over_set_context() { + // When both are configured, a denied request must NOT have its context + // mutated — operators rely on this to avoid leaking partial state to + // downstream plugins for a request that was rejected. + host::reset_context(); + let json = r#"{ + "expression": "true", + "on_match": { + "set_context": { "ai.policy": "should-not-be-set" }, + "deny": { "code": "model_not_permitted" } + } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + match config.on_request(create_request()) { + Action::Continue(_) => panic!("expected deny"), + Action::ShortCircuit(resp) => assert_eq!(resp.status, 403), + } + let ctx = host::get_context(); + assert!( + !ctx.contains_key("ai.policy"), + "deny should not write context, found {:?}", + ctx + ); + } + + #[test] + fn on_match_deny_no_op_when_expression_false() { + // Expression false → continue regardless of `on_match.deny`. Matches the + // existing `set_context` semantics and the ADR's "match-and-take-action" + // reading. + let json = r#"{ + "expression": "false", + "on_match": { "deny": { "code": "model_not_permitted" } } + }"#; + let mut config: CelPolicy = serde_json::from_str(json).expect("config parses"); + match config.on_request(create_request()) { + Action::Continue(_) => {} + Action::ShortCircuit(resp) => panic!("expected continue, got {}", resp.status), + } + } + + #[test] + fn on_match_unknown_field_is_rejected() { + // Regression test: previously OnMatch silently accepted unknown fields, + // so `on_match: { deny: {...} }` was a no-op against a plugin that only + // knew `set_context`. Now both OnMatch and DenyAction reject unknown + // fields explicitly so operator typos surface at config-load time. + let json = r#"{ + "expression": "true", + "on_match": { "deny_typo": { "code": "x" } } + }"#; + let err = match serde_json::from_str::(json) { + Ok(_) => panic!("expected unknown field rejection"), + Err(e) => e, + }; + assert!( + err.to_string().contains("deny_typo"), + "error should mention the unknown field: {}", + err + ); + } + #[test] fn eval_body_json_vendor_plus_json_content_type() { let mut config = create_config("request.body_json.kind == 'event'");