Skip to content

Commit 2d32c4f

Browse files
committed
feat(cel): bind request.body_json for application/json content-types
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.
1 parent 157f6ce commit 2d32c4f

4 files changed

Lines changed: 183 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **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.
1313
- **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.
1414
- **cli**: `barbacane init` now scaffolds a `specs/` directory and places the generated spec in `specs/api.yaml` with `specs: ./specs/` in the manifest.
15+
- **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.
1516

1617
#### AI Gateway middlewares (ADR-0024)
1718
- **`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.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<a href="https://github.com/barbacane-dev/barbacane/actions/workflows/ci.yml"><img src="https://github.com/barbacane-dev/barbacane/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
1111
<a href="https://docs.barbacane.dev"><img src="https://img.shields.io/badge/docs-docs.barbacane.dev-blue" alt="Documentation"></a>
1212
<img src="https://img.shields.io/badge/unit%20tests-517%20passing-brightgreen" alt="Unit Tests">
13-
<img src="https://img.shields.io/badge/plugin%20tests-777%20passing-brightgreen" alt="Plugin Tests">
13+
<img src="https://img.shields.io/badge/plugin%20tests-784%20passing-brightgreen" alt="Plugin Tests">
1414
<img src="https://img.shields.io/badge/integration%20tests-275%20passing-brightgreen" alt="Integration Tests">
1515
<img src="https://img.shields.io/badge/cli%20tests-23%20passing-brightgreen" alt="CLI Tests">
1616
<img src="https://img.shields.io/badge/ui%20tests-44%20passing-brightgreen" alt="UI Tests">

plugins/cel/Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/cel/src/lib.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ impl CelPolicy {
125125
"body".to_string(),
126126
str_val(req.body_str().unwrap_or("")),
127127
);
128+
request_map.insert("body_json".to_string(), parse_body_json(req));
128129
request_map.insert("client_ip".to_string(), str_val(&req.client_ip));
129130

130131
// Headers as a map
@@ -239,6 +240,44 @@ fn btree_to_cel_map(map: &BTreeMap<String, String>) -> cel::Value {
239240
cel_map.into()
240241
}
241242

243+
/// Empty CEL map. Returned as the `request.body_json` value when the body
244+
/// can't be parsed as JSON — keeps `has(request.body_json.x)` semantics clean.
245+
fn empty_cel_map() -> cel::Value {
246+
HashMap::<String, cel::Value>::new().into()
247+
}
248+
249+
/// Parse the request body as JSON when the inbound `content-type` advertises it
250+
/// (`application/json` or any `application/*+json` vendor type, ignoring `;`-suffixed
251+
/// parameters). Returns an empty map for non-JSON content-types and for malformed
252+
/// bodies; the latter logs a warning so operators see policy mis-applies, but never
253+
/// short-circuits the request — a CEL plugin that rejected on every malformed JSON
254+
/// would let an attacker take down every downstream policy by sending one bad byte.
255+
fn parse_body_json(req: &Request) -> cel::Value {
256+
let content_type = match req.headers.get("content-type") {
257+
Some(v) => v.split(';').next().unwrap_or("").trim().to_ascii_lowercase(),
258+
None => return empty_cel_map(),
259+
};
260+
let is_json = content_type == "application/json"
261+
|| (content_type.starts_with("application/") && content_type.ends_with("+json"));
262+
if !is_json {
263+
return empty_cel_map();
264+
}
265+
let body = match req.body_str() {
266+
Some(s) if !s.is_empty() => s,
267+
_ => return empty_cel_map(),
268+
};
269+
match serde_json::from_str::<serde_json::Value>(body) {
270+
Ok(v) => json_to_cel(v),
271+
Err(e) => {
272+
host::log_warn(&format!(
273+
"cel: request body advertised {} but could not be parsed as JSON: {}",
274+
content_type, e
275+
));
276+
empty_cel_map()
277+
}
278+
}
279+
}
280+
242281
/// Convert a serde_json::Value to a CEL value.
243282
fn json_to_cel(value: serde_json::Value) -> cel::Value {
244283
match value {
@@ -304,6 +343,14 @@ mod host {
304343
);
305344
}
306345
}
346+
347+
pub fn log_warn(msg: &str) {
348+
#[link(wasm_import_module = "barbacane")]
349+
extern "C" {
350+
fn host_log(level: i32, msg_ptr: i32, msg_len: i32);
351+
}
352+
unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) }
353+
}
307354
}
308355

309356
#[cfg(not(target_arch = "wasm32"))]
@@ -313,6 +360,7 @@ mod host {
313360

314361
thread_local! {
315362
static CONTEXT: RefCell<BTreeMap<String, String>> = const { RefCell::new(BTreeMap::new()) };
363+
static WARNINGS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
316364
}
317365

318366
pub fn context_set(key: &str, value: &str) {
@@ -321,6 +369,10 @@ mod host {
321369
});
322370
}
323371

372+
pub fn log_warn(msg: &str) {
373+
WARNINGS.with(|w| w.borrow_mut().push(msg.to_string()));
374+
}
375+
324376
#[cfg(test)]
325377
pub fn get_context() -> BTreeMap<String, String> {
326378
CONTEXT.with(|ctx| ctx.borrow().clone())
@@ -330,6 +382,11 @@ mod host {
330382
pub fn reset_context() {
331383
CONTEXT.with(|ctx| ctx.borrow_mut().clear());
332384
}
385+
386+
#[cfg(test)]
387+
pub fn take_warnings() -> Vec<String> {
388+
WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
389+
}
333390
}
334391

335392
// ---------------------------------------------------------------------------
@@ -647,6 +704,128 @@ mod tests {
647704
}
648705
}
649706

707+
// --- request.body_json access (ADR-0030) ---
708+
709+
#[test]
710+
fn eval_body_json_field_access() {
711+
let mut config = create_config("request.body_json.foo == 'bar'");
712+
let mut req = create_request();
713+
req.body = Some(br#"{"foo":"bar"}"#.to_vec());
714+
match config.on_request(req) {
715+
Action::Continue(_) => {}
716+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
717+
}
718+
}
719+
720+
#[test]
721+
fn eval_body_json_ai_consumer_policy_example() {
722+
// The motivating ADR-0030 example: per-tier model gating. CEL access-control
723+
// mode treats `true` as allow / `false` as deny, so the policy is the
724+
// negation of "non-premium asks for gpt-4o".
725+
let mut config = create_config(
726+
"!(request.body_json.model.startsWith('gpt-4o') && request.claims.tier != 'premium')",
727+
);
728+
729+
let mut req_blocked = create_request();
730+
req_blocked.headers.insert(
731+
"x-auth-claims".to_string(),
732+
r#"{"tier":"free"}"#.to_string(),
733+
);
734+
req_blocked.body = Some(br#"{"model":"gpt-4o-mini"}"#.to_vec());
735+
match config.on_request(req_blocked) {
736+
Action::Continue(_) => panic!("expected 403 for free tier on gpt-4o-mini"),
737+
Action::ShortCircuit(resp) => assert_eq!(resp.status, 403),
738+
}
739+
740+
let mut req_allowed = create_request();
741+
req_allowed.headers.insert(
742+
"x-auth-claims".to_string(),
743+
r#"{"tier":"premium"}"#.to_string(),
744+
);
745+
req_allowed.body = Some(br#"{"model":"gpt-4o"}"#.to_vec());
746+
match config.on_request(req_allowed) {
747+
Action::Continue(_) => {}
748+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
749+
}
750+
}
751+
752+
#[test]
753+
fn eval_body_json_vendor_plus_json_content_type() {
754+
let mut config = create_config("request.body_json.kind == 'event'");
755+
let mut req = create_request();
756+
req.headers.insert(
757+
"content-type".to_string(),
758+
"application/vnd.api+json".to_string(),
759+
);
760+
req.body = Some(br#"{"kind":"event"}"#.to_vec());
761+
match config.on_request(req) {
762+
Action::Continue(_) => {}
763+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
764+
}
765+
}
766+
767+
#[test]
768+
fn eval_body_json_content_type_with_charset_param() {
769+
let mut config = create_config("request.body_json.foo == 'bar'");
770+
let mut req = create_request();
771+
req.headers.insert(
772+
"content-type".to_string(),
773+
"application/json; charset=utf-8".to_string(),
774+
);
775+
req.body = Some(br#"{"foo":"bar"}"#.to_vec());
776+
match config.on_request(req) {
777+
Action::Continue(_) => {}
778+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
779+
}
780+
}
781+
782+
#[test]
783+
fn eval_body_json_non_json_content_type_yields_empty_map() {
784+
// text/plain → body_json is an empty map → has() returns false, not an error.
785+
let mut config = create_config("!has(request.body_json.foo)");
786+
let mut req = create_request();
787+
req.headers
788+
.insert("content-type".to_string(), "text/plain".to_string());
789+
req.body = Some(b"this is not json".to_vec());
790+
match config.on_request(req) {
791+
Action::Continue(_) => {}
792+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
793+
}
794+
}
795+
796+
#[test]
797+
fn eval_body_json_malformed_body_logs_warning_and_yields_empty_map() {
798+
// Malformed JSON with a JSON content-type must NOT short-circuit the request —
799+
// a CEL plugin that 500s on every garbled body would let an attacker take down
800+
// every downstream policy with one bad byte. Instead: empty map + log warning.
801+
let _ = host::take_warnings(); // clear any prior test's warnings
802+
803+
let mut config = create_config("!has(request.body_json.foo)");
804+
let mut req = create_request();
805+
req.body = Some(b"not-actually-json{".to_vec());
806+
match config.on_request(req) {
807+
Action::Continue(_) => {}
808+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
809+
}
810+
811+
let warnings = host::take_warnings();
812+
assert!(
813+
warnings.iter().any(|w| w.contains("could not be parsed as JSON")),
814+
"expected a parse-failure warning, got {:?}",
815+
warnings
816+
);
817+
}
818+
819+
#[test]
820+
fn eval_body_json_empty_body_yields_empty_map() {
821+
let mut config = create_config("!has(request.body_json.foo)");
822+
let req = create_request(); // body is None
823+
match config.on_request(req) {
824+
Action::Continue(_) => {}
825+
Action::ShortCircuit(resp) => panic!("expected continue, got status {}", resp.status),
826+
}
827+
}
828+
650829
// --- Error handling ---
651830

652831
#[test]

0 commit comments

Comments
 (0)