diff --git a/Cargo.lock b/Cargo.lock index b4d53b2b..67bd3c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,23 @@ dependencies = [ "libc", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -203,6 +220,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "apl-pdp-cel" +version = "0.2.0" +dependencies = [ + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cel", + "cpex-core", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "apl-pii-scanner" version = "0.2.0" @@ -341,6 +376,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "biscuit-auth" version = "6.0.0" @@ -613,6 +654,22 @@ dependencies = [ "zip", ] +[[package]] +name = "cel" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a40f338a8c3505921000b609279775792c07cc21f97a3011578c0c5e1738ae" +dependencies = [ + "antlr4rust", + "chrono", + "lazy_static", + "nom", + "pastey", + "regex", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -2305,6 +2362,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2476,6 +2542,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index fbe11085..5021d462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/apl-cmf", "crates/apl-cpex", "crates/apl-pdp-cedar-direct", + "crates/apl-pdp-cel", "crates/apl-cedarling", "crates/apl-identity-jwt", "crates/apl-delegator-oauth", @@ -44,6 +45,7 @@ default-members = [ "crates/apl-cmf", "crates/apl-cpex", "crates/apl-pdp-cedar-direct", + "crates/apl-pdp-cel", "crates/apl-identity-jwt", "crates/apl-delegator-oauth", "crates/apl-delegator-biscuit", diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index 2a41ff86..aa45089f 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -566,10 +566,10 @@ fn parse_require_rule(line: &str) -> Result { }) } -/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(`. +/// Detect `taint(...)` / `plugin(...)` / `cedar:` / `cedarling:` / `opa(` / `authzen(` / `nemo(` / `cel:`. fn detect_step_kind(s: &str) -> Option<&'static str> { let s = s.trim_start(); - for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "sequential:", "parallel:"] { + for prefix in ["taint(", "plugin(", "cedar:", "cedarling:", "opa(", "authzen(", "nemo(", "cel:", "sequential:", "parallel:"] { if s.starts_with(prefix) { return Some(prefix.trim_end_matches('(').trim_end_matches(':')); } @@ -1168,7 +1168,7 @@ fn is_known_pdp_dialect(key: &str) -> bool { let base = key.find('(').map(|i| &key[..i]).unwrap_or(key); matches!( base.trim(), - "cedar" | "cedarling" | "opa" | "authzen" | "nemo" + "cedar" | "cedarling" | "opa" | "authzen" | "nemo" | "cel" ) } @@ -3123,6 +3123,35 @@ routes: } } + #[test] + fn compile_pdp_call_cel_map_form() { + // `cel:` carries an `expr:` string + optional on_deny/on_allow + // reactions. Routes to the CEL-backed resolver via PdpDialect::Cel. + let yaml = r#" +routes: + authz_check: + policy: + - cel: + expr: "subject.id == 'alice' && delegation.depth <= 2" + on_deny: + - deny +"#; + let routes = compile_config(yaml).unwrap().routes; + let route = routes.get("authz_check").unwrap(); + match &route.policy[0] { + Effect::Pdp { call, on_deny, on_allow } => { + assert_eq!(call.dialect, PdpDialect::Cel); + let args_map = call.args.as_mapping().expect("cel args should be a map"); + assert!(args_map.contains_key(serde_yaml::Value::String("expr".into()))); + // Reaction keys are stripped from the opaque call args. + assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into()))); + assert_eq!(on_deny.len(), 1); + assert_eq!(on_allow.len(), 0); + } + other => panic!("expected Effect::Pdp, got {:?}", other), + } + } + #[test] fn compile_pdp_call_cedarling_map_form() { // `cedarling:` is its own dialect — same map shape as `cedar:` diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index dca1921e..15e49f54 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -8,8 +8,8 @@ // The DSL allows policy:/post_policy: lists to contain three kinds of // entries beyond predicate-and-action rules: // -// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)` -// with optional `on_deny:` / `on_allow:` reaction blocks +// - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)`, +// `cel:(...)` with optional `on_deny:` / `on_allow:` reaction blocks // - Plugin invocations: `plugin(name)` // - Taint effects: `taint(label[, scope])` // @@ -163,6 +163,15 @@ pub enum PdpDialect { Opa, AuthZen, NeMo, + /// CEL (Common Expression Language) evaluation — `apl-pdp-cel`. + /// The `cel:` step carries an `expr:` string that must evaluate to a + /// boolean against the policy `AttributeBag` (exposed to CEL as nested + /// namespaces: `subject.id`, `delegation.depth`, `session.labels`, …). + /// A small, safe, non-Turing-complete predicate language — distinct + /// from the full PDPs (Cedar/OPA) so all can coexist on one + /// `PdpRouter`. The canonical route-YAML form is the block map + /// `cel: { expr: "..." }`; the `cel:(...)` call form is also accepted. + Cel, #[serde(untagged)] Custom(String), } @@ -178,6 +187,7 @@ impl PdpDialect { "opa" => Self::Opa, "authzen" => Self::AuthZen, "nemo" => Self::NeMo, + "cel" => Self::Cel, other => Self::Custom(other.to_string()), } } @@ -504,3 +514,36 @@ pub mod delegation_bag_keys { /// when the most recent one denied. pub const GRANTED: &str = "delegation.granted"; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_key_maps_known_dialects() { + assert_eq!(PdpDialect::from_key("cedar"), PdpDialect::Cedar); + assert_eq!(PdpDialect::from_key("cedarling"), PdpDialect::Cedarling); + assert_eq!(PdpDialect::from_key("opa"), PdpDialect::Opa); + assert_eq!(PdpDialect::from_key("authzen"), PdpDialect::AuthZen); + assert_eq!(PdpDialect::from_key("nemo"), PdpDialect::NeMo); + assert_eq!(PdpDialect::from_key("cel"), PdpDialect::Cel); + } + + #[test] + fn from_key_unknown_is_custom() { + assert_eq!( + PdpDialect::from_key("rego-remote"), + PdpDialect::Custom("rego-remote".to_string()) + ); + } + + #[test] + fn cel_dialect_serde_roundtrips_as_snake_case() { + // `Cel` is a tagged variant (snake_case) — must round-trip so + // compiled-route serialization (audit/cache) preserves it. + let json = serde_json::to_string(&PdpDialect::Cel).unwrap(); + assert_eq!(json, "\"cel\""); + let back: PdpDialect = serde_json::from_str(&json).unwrap(); + assert_eq!(back, PdpDialect::Cel); + } +} diff --git a/crates/apl-cpex/src/pdp_router.rs b/crates/apl-cpex/src/pdp_router.rs index bbfa12fa..23cb2de0 100644 --- a/crates/apl-cpex/src/pdp_router.rs +++ b/crates/apl-cpex/src/pdp_router.rs @@ -5,8 +5,20 @@ // // `PdpRouter` — composite `PdpResolver` that dispatches each call to the // resolver matching the requested `PdpDialect`. Lets a single host (or a -// single `AplRouteHandler`) carry resolvers for Cedar **and** OPA **and** -// NeMo at the same time without having to pick one at construction. +// single `AplRouteHandler`) carry resolvers for several backends at the +// same time without having to pick one at construction. +// +// The PDP backends that ship in this workspace, each its own crate +// registered here by dialect: +// +// - **cedar** (`apl-pdp-cedar-direct`) / **cedarling** (`apl-cedarling`) +// — Cedar policy-set evaluation, in-process and via Cedarling. +// - **opa** — Open Policy Agent / Rego. +// - **authzen** — AuthZen-protocol external decision point. +// - **nemo** — NeMo reasoning backend. +// - **cel** (`apl-pdp-cel`) — inline CEL boolean predicates authored in +// the route YAML (`cel: { expr: "..." }`); smallest dep tree, no +// external policy store. // // Routing is by dialect equality. The first registered resolver for a // given dialect wins on duplicate registration — registering Cedar twice diff --git a/crates/apl-pdp-cel/Cargo.toml b/crates/apl-pdp-cel/Cargo.toml new file mode 100644 index 00000000..61f618e2 --- /dev/null +++ b/crates/apl-pdp-cel/Cargo.toml @@ -0,0 +1,57 @@ +# Location: ./crates/apl-pdp-cel/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# apl-pdp-cel — a `PdpResolver` that evaluates CEL (Common Expression +# Language) boolean predicates against the policy `AttributeBag`, authored +# inline in route YAML (`cel: { expr: "..." }`). +# +# See the crate-level module docs in `src/lib.rs` for the full picture: +# where it sits in the stack, the bag→CEL activation, the decision +# contract, when to choose CEL vs Cedar/OPA, and why evaluation is +# synchronous and side-effect-free. + +[package] +name = "apl-pdp-cel" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +apl-core = { path = "../apl-core" } +# The CEL interpreter from cel-rust/cel-rust (formerly +# clarkmcc/cel-rust). Sync eval, comprehension macros (`has`, `all`, +# `exists`, `map`, `filter`), custom functions. Caret spec tracks 0.x +# patch/minor; pin tighter if the activation API churns. +# +# Features pinned explicitly so a future change to the upstream +# `default = [...]` set can't silently add or remove capabilities +# operator policies depend on: +# - regex: enables `matches(s, pattern)` for URL/path predicates +# ("did the request target match `^/api/v1/`?"); virtually every +# real policy needs this. +# - chrono: enables `timestamp()`, `duration()`, and date/time +# arithmetic for time-window policies ("business hours", "this +# credential is fresh enough"). +# `json` and `bytes` are deliberately off — APL marshals JSON at its +# own layer, and CEL bytes ops aren't needed for ABAC predicates. +cel = { version = "0.13", default-features = false, features = ["regex", "chrono"] } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# End-to-end integration tests wire the cel factory through the apl-cpex +# visitor and exercise it against a real `PluginManager`. These dev-dep +# edges only exist for tests — the crate itself stays apl-core-only at +# compile time so it can be used standalone (e.g. in a custom orchestrator +# that doesn't go through apl-cpex at all). +apl-cmf = { path = "../apl-cmf" } +apl-cpex = { path = "../apl-cpex" } +cpex-core = { path = "../cpex-core" } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/apl-pdp-cel/src/activation.rs b/crates/apl-pdp-cel/src/activation.rs new file mode 100644 index 00000000..488296a9 --- /dev/null +++ b/crates/apl-pdp-cel/src/activation.rs @@ -0,0 +1,370 @@ +// Location: ./crates/apl-pdp-cel/src/activation.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Bag → CEL activation mapping. +// +// APL's `AttributeBag` is a flat `HashMap` with +// dotted keys (`subject.id`, `role.hr`, `delegation.depth`). CEL wants +// nested structures so `subject.id` reads as field selection on a +// `subject` map. This module rebuilds the flat bag into a tree of CEL +// maps and registers each top-level namespace as a CEL variable. +// +// Type mapping (`AttributeValue` → `cel::Value`): +// Bool → Value::Bool +// Int → Value::Int +// Float → Value::Float +// String → Value::String +// StringSet → Value::List(of String) (so `"x" in session.labels` works) +// +// Collision rule: if a key is both a leaf and a namespace prefix +// (`delegation` AND `delegation.depth`), the namespace (map) wins and the +// scalar leaf is dropped with a `tracing::warn!`. In practice the cmf +// BagBuilder never emits both, but the bag is an open namespace so we +// resolve it deterministically rather than panic. + +use std::collections::{BTreeMap, HashMap}; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use cel::{Context, Value}; + +/// Build a CEL evaluation context from the policy bag plus the `cel:` +/// step's extra args. +/// +/// - Every dotted bag key becomes nested CEL maps; each top-level segment +/// (`subject`, `role`, `delegation`, `session`, `args`, …) is registered +/// as a CEL variable. +/// - Each top-level key of `extra_args` (everything the author put under +/// `cel:` besides `expr`) is registered as an additional variable — +/// e.g. `resource`, `context` — mirroring how `cedar:` surfaces them. +/// - On a name collision between an `extra_args` key and a bag namespace, +/// the **bag wins** (the bag is the authoritative, framework-populated +/// vocabulary; args can't shadow it by accident). +/// +/// The returned context also carries CEL's standard function/macro library +/// (via `Context::default`), so `has()`, `size()`, `all()`, `exists()`, +/// `map()`, `filter()`, string methods, etc. are all available. +pub fn bag_to_context(bag: &AttributeBag, extra_args: &serde_yaml::Value) -> Context<'static> { + let mut ctx = Context::default(); + + // 1. Author-supplied extra args first (so the bag overrides on + // collision). Skip `expr` — that's the program text, not a + // variable. + let mut extra_names: std::collections::HashSet = std::collections::HashSet::new(); + if let Some(map) = extra_args.as_mapping() { + for (k, v) in map { + let Some(name) = k.as_str() else { continue }; + if name == "expr" { + continue; + } + extra_names.insert(name.to_string()); + ctx.add_variable_from_value(name.to_string(), yaml_to_value(v)); + } + } + + // 2. The bag namespaces (authoritative). Build the tree, then register + // each top-level node as a variable. Log when a bag namespace + // shadows an author-supplied extra arg with the same name — the + // bag wins by design, but a silent shadow can mask a typo in the + // author's args block. + let root = build_tree(bag); + for (name, node) in root { + if extra_names.contains(&name) { + tracing::debug!( + name = %name, + "CEL activation: bag namespace shadows an extra-arg of the same name; \ + bag value wins by design", + ); + } + ctx.add_variable_from_value(name, node_to_value(node)); + } + + ctx +} + +/// Internal tree node: either a leaf scalar/list or a nested namespace. +enum Node { + Leaf(Value), + Branch(BTreeMap), +} + +/// Build the top-level namespace tree from the flat, dotted bag. +fn build_tree(bag: &AttributeBag) -> BTreeMap { + let mut root: BTreeMap = BTreeMap::new(); + for (key, value) in bag.iter() { + let segments: Vec<&str> = key.split('.').collect(); + insert(&mut root, key, &segments, attr_to_value(value)); + } + root +} + +/// Insert a leaf at the dotted path, creating intermediate branches. +/// Namespace-wins on leaf/branch collisions (see module docs). +fn insert(level: &mut BTreeMap, full_key: &str, segments: &[&str], leaf: Value) { + // `bag.iter()` never yields empty keys today, but iterator + // contracts can drift — return cleanly rather than panic if a + // future bag implementation emits one. The caller's leaf is just + // dropped; no name to insert under. + let Some((head, rest)) = segments.split_first() else { + return; + }; + let head = (*head).to_string(); + + if rest.is_empty() { + // Terminal segment — place the leaf, unless a namespace already + // claimed this name (namespace wins). + match level.get(&head) { + Some(Node::Branch(_)) => { + tracing::warn!( + key = %full_key, + "CEL activation: scalar key collides with an existing namespace; \ + keeping the namespace and dropping the scalar" + ); + } + _ => { + level.insert(head, Node::Leaf(leaf)); + } + } + return; + } + + // Intermediate segment — descend, converting a leaf into a branch if + // needed (namespace wins). + let entry = level.entry(head).or_insert_with(|| Node::Branch(BTreeMap::new())); + if let Node::Leaf(_) = entry { + tracing::warn!( + key = %full_key, + "CEL activation: namespace prefix collides with an existing scalar; \ + promoting to a namespace and dropping the scalar" + ); + *entry = Node::Branch(BTreeMap::new()); + } + if let Node::Branch(child) = entry { + insert(child, full_key, rest, leaf); + } +} + +/// Recursively convert a tree node into a `cel::Value`. +fn node_to_value(node: Node) -> Value { + match node { + Node::Leaf(v) => v, + Node::Branch(children) => { + let map: HashMap = children + .into_iter() + .map(|(k, child)| (k, node_to_value(child))) + .collect(); + Value::from(map) + } + } +} + +/// Convert one `AttributeValue` to a `cel::Value`. +/// +/// CEL's type model distinguishes `int` and `double` strictly: +/// `delegation.depth <= 2` errors if `delegation.depth` is a double +/// and `2` is an int (the literal). To shield authors from that +/// asymmetry, an `f64` whose value is a whole number and fits a `i64` +/// is yielded as `Value::Int`. The same logic applies to the +/// author-supplied yaml args (see `yaml_to_value`) — both surfaces +/// now agree. +fn attr_to_value(attr: &AttributeValue) -> Value { + match attr { + AttributeValue::Bool(b) => Value::from(*b), + AttributeValue::Int(i) => Value::from(*i), + AttributeValue::Float(f) => float_to_value(*f), + AttributeValue::String(s) => Value::from(s.clone()), + // StringSet → list(string). Sort before yielding so authors + // who reach for `session.labels[0]` (or any other + // index-dependent operation) get a stable answer across runs + // and rust releases. `in` / `exists` / `all` / `filter` don't + // care about order, but determinism by construction beats + // "works on my machine" when the policy ever indexes. + AttributeValue::StringSet(set) => { + let mut sorted: Vec<&String> = set.iter().collect(); + sorted.sort(); + let items: Vec = sorted.into_iter().map(|s| Value::from(s.clone())).collect(); + Value::from(items) + } + } +} + +/// Yield an `f64` as `Value::Int` when it represents a whole number +/// in `i64` range, otherwise `Value::Float`. Used by both +/// `attr_to_value` (bag scalars) and `yaml_to_value` (author args) so +/// `delegation.depth: 2` works against the literal `2` regardless of +/// whether the bag populated it as `Int(2)` or `Float(2.0)`. +fn float_to_value(f: f64) -> Value { + if f.is_finite() && f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 { + Value::from(f as i64) + } else { + Value::from(f) + } +} + +/// Convert a `serde_yaml::Value` (author-supplied `cel:` args) to a +/// `cel::Value`. Numbers without a fractional part map to `Int`, otherwise +/// `Float`. Non-string mapping keys are skipped (CEL map keys here are +/// always strings for author ergonomics). +fn yaml_to_value(v: &serde_yaml::Value) -> Value { + match v { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::from(*b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::from(i) + } else { + float_to_value(n.as_f64().unwrap_or(f64::NAN)) + } + } + serde_yaml::Value::String(s) => Value::from(s.clone()), + serde_yaml::Value::Sequence(seq) => { + let items: Vec = seq.iter().map(yaml_to_value).collect(); + Value::from(items) + } + serde_yaml::Value::Mapping(map) => { + let mut out: HashMap = HashMap::new(); + for (k, val) in map { + if let Some(name) = k.as_str() { + out.insert(name.to_string(), yaml_to_value(val)); + } + } + Value::from(out) + } + // serde_yaml's tagged values are not used in APL configs; treat as null. + _ => Value::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn run_cel(expr: &str, ctx: &Context<'static>) -> Result { + let program = cel::Program::compile(expr).map_err(|e| e.to_string())?; + program.execute(ctx).map_err(|e| e.to_string()) + } + + fn truthy(expr: &str, bag: &AttributeBag) -> bool { + let ctx = bag_to_context(bag, &serde_yaml::Value::Null); + matches!(run_cel(expr, &ctx), Ok(Value::Bool(true))) + } + + #[test] + fn dotted_keys_become_nested_maps() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "user"); + assert!(truthy("subject.id == 'alice'", &bag)); + assert!(truthy("subject.type == 'user'", &bag)); + } + + #[test] + fn bool_int_float_scalars() { + let mut bag = AttributeBag::new(); + bag.set("role.hr", true); + bag.set("delegation.depth", 2_i64); + bag.set("intent.confidence", 0.92_f64); + assert!(truthy("role.hr", &bag)); + assert!(truthy("delegation.depth <= 2", &bag)); + assert!(truthy("intent.confidence > 0.9", &bag)); + } + + #[test] + fn single_segment_key_is_top_level_variable() { + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + assert!(truthy("authenticated", &bag)); + } + + #[test] + fn string_set_becomes_list_for_in_operator() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["PII".to_string(), "compensation".to_string()]), + ); + assert!(truthy("'PII' in session.labels", &bag)); + assert!(truthy("'compensation' in session.labels", &bag)); + assert!(truthy("!('PHI' in session.labels)", &bag)); + // Comprehension macros work over the list too. + assert!(truthy("session.labels.exists(l, l == 'PII')", &bag)); + } + + /// An `f64` whose value is a whole number is yielded as an int so + /// authors can compare against integer literals without CEL's + /// strict int-vs-double type rules blowing up. A genuinely + /// fractional `f64` still arrives as a float (so `confidence > 0.9` + /// behaves correctly). + #[test] + fn whole_number_float_arrives_as_int_for_literal_compare() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 2.0_f64); + bag.set("intent.confidence", 0.92_f64); + // Compare-with-int-literal: requires the bag value to be int. + assert!(truthy("delegation.depth == 2", &bag)); + assert!(truthy("delegation.depth <= 2", &bag)); + // Genuine doubles still compare to double literals. + assert!(truthy("intent.confidence > 0.9", &bag)); + } + + /// `StringSet` is yielded in sorted order so indexing returns a + /// stable value across runs. `"compensation" < "PII"` (ASCII; + /// uppercase letters sort before lowercase, but both labels here + /// are different cases so ordering is alphanumeric on the first + /// char). Pinning the order keeps an author who reaches for + /// `session.labels[0]` from getting different answers between + /// builds. + #[test] + fn string_set_yields_sorted_order_for_stable_indexing() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["zeta".to_string(), "alpha".to_string(), "mu".to_string()]), + ); + assert!(truthy("session.labels[0] == 'alpha'", &bag)); + assert!(truthy("session.labels[1] == 'mu'", &bag)); + assert!(truthy("session.labels[2] == 'zeta'", &bag)); + } + + #[test] + fn has_macro_guards_optional_fields() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + // `subject` exists but has no `email` field → has() is false. + assert!(truthy("has(subject.id) && !has(subject.email)", &bag)); + } + + #[test] + fn extra_args_surface_as_variables_bag_wins_on_collision() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + let args = serde_yaml::from_str::( + "resource:\n kind: document\n sensitivity: 3\nsubject: shadowed\n", + ) + .unwrap(); + let ctx = bag_to_context(&bag, &args); + // Author-supplied `resource` is visible. + assert!(matches!( + run_cel("resource.kind == 'document' && resource.sensitivity == 3", &ctx), + Ok(Value::Bool(true)) + )); + // `subject` from the bag wins over the args' `subject: shadowed`. + assert!(matches!( + run_cel("subject.id == 'alice'", &ctx), + Ok(Value::Bool(true)) + )); + } + + #[test] + fn namespace_wins_on_leaf_collision() { + // Both `delegation` (scalar) and `delegation.depth` (under a + // namespace) present — the namespace must win so `delegation.depth` + // resolves rather than erroring on a scalar field access. + let mut bag = AttributeBag::new(); + bag.set("delegation", "scalar-value"); + bag.set("delegation.depth", 3_i64); + assert!(truthy("delegation.depth == 3", &bag)); + } +} diff --git a/crates/apl-pdp-cel/src/error.rs b/crates/apl-pdp-cel/src/error.rs new file mode 100644 index 00000000..2b8e52b1 --- /dev/null +++ b/crates/apl-pdp-cel/src/error.rs @@ -0,0 +1,30 @@ +// Location: ./crates/apl-pdp-cel/src/error.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Build-time errors for `CelResolver`. These fire at construction +// (parsing the unified-config block); never at request time. +// +// Request-time problems (a bad `expr`, an undeclared variable, a +// non-boolean result) flow through `apl_core::PdpError` / a fail-closed +// `PdpDecision::Deny` because that's the trait's return surface — +// deliberately separate from build errors, which are config faults the +// operator fixes once. +// +// `BuildError` implements `std::error::Error` (via thiserror), so it +// boxes cleanly into `apl_cpex::visitor::VisitorError` when the +// AplConfigVisitor builds a resolver from a unified-config block. The +// visitor wraps that into `cpex_core::PluginError::Config` on its way out +// of `load_config_yaml`. + +use thiserror::Error; + +/// Error returned at resolver construction (`CelResolver::from_config`). +#[derive(Debug, Error)] +pub enum BuildError { + /// Config block wasn't a mapping, or a field had the wrong shape / + /// an unrecognized value (e.g. `on_error: maybe`). + #[error("invalid CEL PDP config: {0}")] + ConfigShape(String), +} diff --git a/crates/apl-pdp-cel/src/factory.rs b/crates/apl-pdp-cel/src/factory.rs new file mode 100644 index 00000000..caa26a45 --- /dev/null +++ b/crates/apl-pdp-cel/src/factory.rs @@ -0,0 +1,51 @@ +// Location: ./crates/apl-pdp-cel/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CelPdpFactory` — the `PdpFactory` implementation that lets the apl-cpex +// visitor instantiate `CelResolver` from a unified-config YAML block: +// +// ```yaml +// global: +// apl: +// pdp: +// - kind: cel +// on_error: deny # optional; deny | allow, default deny +// ``` +// +// The CEL expression itself lives in each route's `cel: { expr: "..." }` +// step, not in this block — so the global config usually just declares the +// resolver exists. Hosts register an instance of this factory in +// `AplOptions.pdp_factories`; the visitor matches it to the block by `kind`. + +use std::sync::Arc; + +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::resolver::CelResolver; + +/// Factory for `CelResolver`. Reports `kind() = "cel"`; builds resolvers +/// from the unified-config block via [`CelResolver::from_config`]. +#[derive(Default)] +pub struct CelPdpFactory; + +impl CelPdpFactory { + pub fn new() -> Self { + Self + } +} + +impl PdpFactory for CelPdpFactory { + fn kind(&self) -> &str { + "cel" + } + + fn build( + &self, + config: &serde_yaml::Value, + ) -> Result, Box> { + let resolver = CelResolver::from_config(config)?; + Ok(Arc::new(resolver)) + } +} diff --git a/crates/apl-pdp-cel/src/lib.rs b/crates/apl-pdp-cel/src/lib.rs new file mode 100644 index 00000000..544a0e8a --- /dev/null +++ b/crates/apl-pdp-cel/src/lib.rs @@ -0,0 +1,126 @@ +// Location: ./crates/apl-pdp-cel/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// apl-pdp-cel — `PdpResolver` over the `cel` (Common Expression Language) +// interpreter. +// +// # Where this lives in the stack +// +// APL evaluator (apl-core) +// │ `cel: { expr: "..." }` step +// ▼ +// PdpRouter (apl-cpex) — dispatches by dialect (PdpDialect::Cel) +// │ resolver.evaluate(call, bag) +// ▼ +// CelResolver — THIS CRATE +// │ bag → CEL activation, compile-once / eval-many +// ▼ +// cel::Program::execute — clarkmcc's CEL interpreter +// +// # Inputs (`PdpCall.args`) +// +// APL routes call CEL like: +// +// ```yaml +// policy: +// - cel: +// expr: | +// subject.id in ["alice", "bob"] +// && delegation.depth <= 2 +// && !("compensation" in session.labels) +// on_deny: +// - deny("cel policy denied access") +// on_allow: +// - taint(audit_pass, session) +// ``` +// +// Required key: `expr` (a string). Any other keys in `args` (e.g. +// `resource`, `context`) are surfaced to the expression as additional +// top-level CEL variables, mirroring how `cedar:` exposes resource/context. +// +// The canonical step form is the block-map shown above (`cel: { expr: +// "..." }`); it's what the integration tests and most policies use. The +// parser also accepts the call form `cel:(expr: "...")` — both compile to +// the same `PdpCall`. Prefer the map form in new policy: it reads cleanly +// when the `expr` spans multiple lines. +// +// # The attribute vocabulary (bag → CEL activation) +// +// APL's `AttributeBag` is a flat namespace of dotted keys +// (`subject.id`, `role.hr`, `delegation.depth`, `session.labels`). The +// resolver rebuilds those into nested CEL maps so authors write natural +// field selection: +// +// - `subject.id` → string `subject.id == "alice"` +// - `role.hr` (=true) → bool `role.hr` +// - `delegation.depth` → int `delegation.depth <= 2` +// - `session.labels` → list(string) `"PII" in session.labels` +// - `intent.confidence` → double `intent.confidence > 0.9` +// +// See `activation::bag_to_context` for the exact mapping and the +// leaf-vs-namespace collision rule. +// +// # Decision contract +// +// The expression MUST evaluate to a boolean. `true → Allow`, +// `false → Deny`. A non-boolean result, an undeclared-variable reference, +// a compile error, or any other evaluation error is **fail-closed → Deny** +// with the cause in `PdpDecision.diagnostics` (matches APL's PDP +// fail-closed default; DSL §8.9). Operators can flip a *runtime* error +// (undeclared variable, type error, non-boolean) to allow-through via +// `on_error: allow` in the PDP config block, but the default is `deny`. +// Compile errors are never flippable — see `resolver::OnError`. +// +// "Non-boolean" means the expression's top-level value is anything other +// than `true`/`false`. Common author mistakes: +// +// - `subject.id` → a string → degenerate → Deny +// - `delegation.depth` → an int → degenerate → Deny +// - `subject.roles` → a list → degenerate → Deny +// - `has(session.token) ? 1 : 0` → an int → degenerate → Deny +// +// Note CEL `null` is its own value, distinct from `false`: an expression +// that yields `null` (e.g. an optional field selected without a guard) is +// non-boolean and therefore Deny under the default — it is NOT treated as +// a `false` policy decision. Guard optional fields with `has(...)` and +// compare explicitly (`has(role.reader) && role.reader`) so the result is +// always a real boolean. +// +// # CEL vs Cedar (which backend?) +// +// Reach for **cel** when the decision is a self-contained boolean +// predicate over the common attribute vocabulary, authored inline in the +// route YAML, with no external policy store — relevance / consistency / +// lightweight ABAC. Reach for **cedar / cedarling / opa** when policy +// lives outside the route (versioned/signed policy sets, central +// management) or needs the full entity/relationship model. CEL trades +// Cedar's policy-set machinery for zero-glue, in-line expressiveness. +// +// # Synchronous by design +// +// CEL evaluation here is synchronous and side-effect-free — no network, +// no I/O, no async. That's deliberate: attribute resolution and any +// side-effecting work (remote lookups, credential exchange) belong in APL +// plugin steps that populate the bag *before* the `cel:` step runs. There +// is no async-CEL path, and custom functions registered via +// `CelResolver::with_functions` should likewise stay pure and fast — they +// run inline on every evaluation while the activation context is held. +// +// # Compile cache +// +// Each distinct `expr` string compiles to a `cel::Program` exactly once; +// the resolver caches programs keyed by source string and reuses them on +// every subsequent call. Because APL compiles route YAML once at config +// load, a given route's `cel:` expression compiles a single time over the +// process lifetime. + +pub mod activation; +pub mod error; +pub mod factory; +pub mod resolver; + +pub use error::BuildError; +pub use factory::CelPdpFactory; +pub use resolver::{CelResolver, OnError}; diff --git a/crates/apl-pdp-cel/src/resolver.rs b/crates/apl-pdp-cel/src/resolver.rs new file mode 100644 index 00000000..4115b286 --- /dev/null +++ b/crates/apl-pdp-cel/src/resolver.rs @@ -0,0 +1,867 @@ +// Location: ./crates/apl-pdp-cel/src/resolver.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// `CelResolver` — the `PdpResolver` implementation. Compiles each distinct +// `cel: { expr: "..." }` expression once (cached by source string) and +// evaluates it against the policy `AttributeBag` on every call. +// +// # Decision contract +// +// - expression → `true` → Allow +// - expression → `false` → Deny (a legitimate policy denial; always honored) +// - non-boolean result, undeclared-variable reference, or any other +// evaluation error → governed by `on_error` (default `Deny`, i.e. +// fail-closed). `on_error: allow` flips these degenerate cases to Allow. +// - a `cel:` step with no `expr` string is a config bug → `PdpError`. +// +// The cause of any Deny / error is recorded in `PdpDecision.diagnostics` +// for audit, and is the `rule_source` on the resulting Deny. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use async_trait::async_trait; +use cel::{Context, Program, Value}; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +use crate::activation::bag_to_context; +use crate::error::BuildError; + +/// What to do when an expression errors at runtime (an undeclared +/// variable, a type error, a custom-function panic) or returns a +/// non-boolean value. A `false` result is never affected — it is +/// always a Deny. +/// +/// **Compile errors are NOT governed by this enum.** A compile error +/// means an author wrote malformed CEL; there's no legitimate reason +/// to flip that to Allow, so it ALWAYS resolves to Deny + a loud +/// `tracing::error!`. If you flipped a compile error to Allow you'd +/// be silently turning malformed policy into "always allow" — which +/// is a security-hostile default we deliberately don't expose. +/// Cache-full rejections (the cap was hit) are treated as eval errors +/// — they're a runtime resource limit, not an author bug. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OnError { + /// Fail-closed: a degenerate runtime outcome denies. The APL + /// default and the safe choice for access decisions. + #[default] + Deny, + /// Fail-open: a degenerate runtime outcome allows through. + /// Intended for when CEL is a soft/advisory check layered behind a + /// hard PDP — but **APL does not enforce that layering**. Nothing + /// stops an operator from making a `cel:` step with `on_error: + /// allow` the only gate on a route, which turns every runtime error + /// into an allow. Layering is the operator's responsibility. The + /// Allow path emits `tracing::error!` (not warn) so runtime errors + /// masquerading as Allows are not invisible in production logs. + Allow, +} + +/// `PdpResolver` that evaluates CEL boolean expressions. Holds a +/// compile cache so each distinct expression string compiles a single +/// time over the resolver's lifetime. +/// Default upper bound on the compile cache. `cel:` steps are author- +/// supplied in route YAML, so the cache fills with the policy's static +/// set of distinct expressions. 1024 is generous for any realistic +/// policy file and small enough that a templating bug (or a future +/// feature that lets steps build exprs from request data) trips the +/// cap before it can balloon memory. +pub const DEFAULT_MAX_CACHE_ENTRIES: usize = 1024; + +/// A function-registration callback. The host calls +/// [`CelResolver::with_functions`] with one of these and the resolver +/// runs it against the `cel::Context` it builds for every evaluation +/// — registering whatever custom functions the host wants exposed to +/// policy authors. The callback gets full access to clarkmcc's +/// `IntoFunction` magic, so closures can be written in the natural +/// `|s: Arc, n: i64| -> bool` form, not just the raw +/// `&mut FunctionContext` shape. +/// +/// The boxed callback is `Send + Sync + 'static` because the resolver +/// is shared across worker threads via `Arc` and lives +/// for the process. +pub type CelFunctionSetup = dyn Fn(&mut Context<'static>) + Send + Sync + 'static; + +pub struct CelResolver { + dialect: PdpDialect, + on_error: OnError, + /// Upper bound on cached compiled programs. `cache_full` rejects + /// new entries past this; existing entries are never evicted (per + /// the workspace-wide "cap + reject + log, never evict" convention, + /// see `feedback_cache_eviction`). + max_cache_entries: usize, + /// Host-supplied custom-function registration callbacks. Each is + /// invoked on every freshly-built `Context` before evaluation so + /// expressions can reference the registered names. Multiple + /// callbacks compose — register e.g. one bundle of regex helpers + /// and one bundle of time helpers. + function_setups: Vec>, + /// Compiled-program cache keyed by expression source. `RwLock` so + /// the steady-state read-many path (every request hits this once + /// the route's expr has compiled the first time) is uncontended + /// — only the rare insert path takes the write lock. APL compiles + /// route YAML once, so the set of distinct exprs is small and + /// fixed; concurrent reads dominate the lifecycle. + cache: RwLock>>, +} + +impl CelResolver { + /// A resolver with default settings (`PdpDialect::Cel`, fail-closed, + /// cache capped at [`DEFAULT_MAX_CACHE_ENTRIES`]). + pub fn new() -> Self { + Self { + dialect: PdpDialect::Cel, + on_error: OnError::Deny, + max_cache_entries: DEFAULT_MAX_CACHE_ENTRIES, + function_setups: Vec::new(), + cache: RwLock::new(HashMap::new()), + } + } + + /// Set the error-handling mode (default `Deny`). + pub fn with_on_error(mut self, on_error: OnError) -> Self { + self.on_error = on_error; + self + } + + /// Override the compile-cache cap (default + /// [`DEFAULT_MAX_CACHE_ENTRIES`]). Past this bound, new exprs are + /// rejected at request time and the call is routed through + /// [`OnError`] — never evict an existing entry. Use this only when + /// you have hard evidence the default is wrong for your policy size. + pub fn with_max_cache_entries(mut self, max_cache_entries: usize) -> Self { + self.max_cache_entries = max_cache_entries; + self + } + + /// Register custom CEL functions. The supplied callback is invoked + /// against every freshly-built evaluation `Context`, so any + /// `add_function` calls it makes are available to author + /// expressions on every request. + /// + /// Composes: calling `with_functions` more than once stacks the + /// callbacks. Each runs in registration order on every context. + /// + /// # Example + /// + /// ```rust,ignore + /// use std::sync::Arc; + /// use apl_pdp_cel::CelResolver; + /// + /// let resolver = CelResolver::new().with_functions(|ctx| { + /// // Regex helper — authors can write `args.path.matches_prefix("/api/")`. + /// ctx.add_function("matches_prefix", + /// |s: Arc, prefix: Arc| -> bool { + /// s.starts_with(prefix.as_str()) + /// }); + /// // Clock helper — authors can write `now() < session.expires_at`. + /// ctx.add_function("now", || -> i64 { + /// std::time::SystemTime::now() + /// .duration_since(std::time::UNIX_EPOCH) + /// .map(|d| d.as_secs() as i64).unwrap_or(0) + /// }); + /// }); + /// ``` + /// + /// Function names that collide with the CEL standard library + /// (`size`, `has`, `matches`, etc.) silently shadow the built-in + /// — be deliberate. + /// + /// # Ownership of the function set + /// + /// The custom-function set is a **host concern**, registered once + /// when the host wires up the resolver (typically via the + /// `CelPdpFactory` in the host project), not authored per-route in + /// policy YAML. The host owns the stable contract of which functions + /// exist; policy authors only call them. Adding or removing a + /// function changes that contract for every route at once, so treat + /// the set like any other host API surface — version it, and avoid + /// renaming/removing functions that live policies depend on. + pub fn with_functions(mut self, setup: F) -> Self + where + F: Fn(&mut Context<'static>) + Send + Sync + 'static, + { + self.function_setups.push(Arc::new(setup)); + self + } + + /// Override the resolver's dialect. Lets operators register a CEL + /// engine under a custom name so two CEL resolvers (e.g. different + /// `on_error` modes) can coexist on one `PdpRouter`. + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + /// Build a resolver from a unified-config block. Shape: + /// + /// ```yaml + /// kind: cel # matched by the factory, not read here + /// on_error: deny # optional; deny | allow, default deny + /// ``` + /// + /// The actual policy predicate isn't on this block — it's inlined + /// at each route's `cel: { expr: "..." }` step. Operators who want + /// to surface bad CEL at *deploy* time rather than at *first + /// request* should ship a CI smoke test that calls + /// `load_config_yaml` against their config and exercises one + /// request per `cel:` step; this resolver doesn't carry an + /// eager-compile knob of its own. + pub fn from_config(value: &serde_yaml::Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| BuildError::ConfigShape("CEL PDP config must be a mapping".into()))?; + + // Reject unknown keys so a typo (`on_errr: deny`) fails loud at + // load rather than being silently dropped and defaulting. `kind` + // is consumed by the visitor/factory but is present on the block; + // `on_error` is the only knob this resolver reads. + const KNOWN_KEYS: &[&str] = &["kind", "on_error"]; + for (key, _) in map { + let Some(name) = key.as_str() else { + return Err(BuildError::ConfigShape( + "CEL PDP config keys must be strings".into(), + )); + }; + if !KNOWN_KEYS.contains(&name) { + return Err(BuildError::ConfigShape(format!( + "unknown CEL PDP config key `{name}`; expected one of {KNOWN_KEYS:?}" + ))); + } + } + + let on_error = match read_yaml_string(map, "on_error").as_deref() { + None | Some("deny") => OnError::Deny, + Some("allow") => OnError::Allow, + Some(other) => { + return Err(BuildError::ConfigShape(format!( + "`on_error` must be `deny` or `allow`, got `{other}`" + ))); + } + }; + + Ok(Self::new().with_on_error(on_error)) + } + + /// Get a compiled program for `expr` from the cache, compiling and + /// caching it on first use. + /// + /// Read-many fast path under the `RwLock` (uncontended once the + /// route's expr has compiled); first-miss falls through to the + /// write lock to compile + insert. APL compiles all routes at + /// `load_config_yaml` time — single-threaded — so the realistic + /// race window is zero. A duplicate concurrent compile would + /// merely overwrite an equivalent entry and drop the loser's + /// `Arc`, so no extra double-checked-locking machinery is + /// warranted here. + /// + /// Cap enforcement: at `max_cache_entries` the next *new* expr is + /// rejected with `CacheFull`. The caller treats it as a degenerate + /// outcome and routes through [`OnError`]. Existing entries are + /// never evicted. + fn get_or_compile(&self, expr: &str) -> Result, GetOrCompileError> { + if let Some(program) = self + .cache + .read() + .unwrap_or_else(|p| p.into_inner()) + .get(expr) + { + return Ok(Arc::clone(program)); + } + let program = Arc::new( + Program::compile(expr).map_err(|e| GetOrCompileError::Compile(e.to_string()))?, + ); + let mut cache = self.cache.write().unwrap_or_else(|p| p.into_inner()); + if cache.len() >= self.max_cache_entries && !cache.contains_key(expr) { + tracing::warn!( + cap = self.max_cache_entries, + "CEL compile cache full; rejecting new expression. Existing entries are not \ + evicted. Increase `with_max_cache_entries` if your policy legitimately exceeds \ + the default bound." + ); + return Err(GetOrCompileError::CacheFull { + cap: self.max_cache_entries, + }); + } + cache.insert(expr.to_string(), Arc::clone(&program)); + Ok(program) + } + + /// Apply the `on_error` policy to a degenerate RUNTIME outcome + /// (eval error, non-boolean result, cache-full rejection), + /// producing a `PdpDecision` with the cause recorded in + /// diagnostics. Allow uses `tracing::error!` (not warn) so an + /// operator misusing the flag sees it loudly in production logs. + /// + /// Compile errors do NOT come through here — see + /// [`Self::compile_error_decision`]. + fn on_error_decision(&self, cause: String) -> PdpDecision { + match self.on_error { + OnError::Allow => { + tracing::error!( + cause = %cause, + "CEL runtime error; on_error=allow → allowing through. \ + This is fail-open behavior; verify it is intentional." + ); + PdpDecision { decision: Decision::Allow, diagnostics: vec![cause] } + } + OnError::Deny => PdpDecision { + decision: Decision::Deny { + reason: Some(cause.clone()), + rule_source: "cel".to_string(), + }, + diagnostics: vec![cause], + }, + } + } + + /// Compile errors always fail closed — a malformed `expr` is an + /// author bug, not a runtime condition, and silently flipping it + /// to Allow would let broken policy bypass the gate. Logs at + /// `error!` so the operator notices in CI / production. + fn compile_error_decision(&self, cause: String) -> PdpDecision { + tracing::error!( + cause = %cause, + "CEL compile error — author-supplied expression failed to parse. \ + Denying request regardless of on_error mode." + ); + PdpDecision { + decision: Decision::Deny { + reason: Some(cause.clone()), + rule_source: "cel".to_string(), + }, + diagnostics: vec![cause], + } + } +} + +impl Default for CelResolver { + fn default() -> Self { + Self::new() + } +} + +/// Internal — failure shapes from `get_or_compile`. Folds into +/// `on_error_decision` at the eval call site; not part of the public +/// surface. +enum GetOrCompileError { + Compile(String), + CacheFull { cap: usize }, +} + +impl std::fmt::Display for GetOrCompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Compile(e) => write!(f, "CEL compile error: {e}"), + Self::CacheFull { cap } => write!( + f, + "CEL compile-cache full (cap={cap}); refusing to compile a new expression. \ + Bump `with_max_cache_entries` if the policy legitimately needs more, otherwise \ + investigate a templating or generation bug producing unbounded distinct exprs." + ), + } + } +} + +#[async_trait] +impl PdpResolver for CelResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate( + &self, + call: &PdpCall, + bag: &AttributeBag, + ) -> Result { + // 1. Pull the expression text from the step args. A `cel:` step + // with no `expr` string is an author/config bug — hard error. + let expr = call + .args + .as_mapping() + .and_then(|m| m.get(serde_yaml::Value::String("expr".into()))) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PdpError::Dispatch( + "cel:() step requires a string `expr` argument".to_string(), + ) + })?; + + // 2. Compile (cached). Compile errors always Deny (an author + // bug, never legitimately flippable). Cache-full rejections + // are runtime conditions and route through on_error. + let program = match self.get_or_compile(expr) { + Ok(p) => p, + Err(e @ GetOrCompileError::Compile(_)) => { + return Ok(self.compile_error_decision(e.to_string())); + } + Err(e @ GetOrCompileError::CacheFull { .. }) => { + return Ok(self.on_error_decision(e.to_string())); + } + }; + + // 3. Build the activation from the bag + author-supplied extra + // args. Then layer any host-supplied custom-function bundles + // on top so expressions can call into them. Setups run in + // registration order; later setups can shadow earlier ones, + // which is the documented contract. + let mut ctx = bag_to_context(bag, &call.args); + for setup in &self.function_setups { + setup(&mut ctx); + } + + // 4. Evaluate and map the result to a decision. + match program.execute(&ctx) { + Ok(Value::Bool(true)) => Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }), + Ok(Value::Bool(false)) => { + // Enrich the deny diagnostics with a snapshot of the + // bag values the expression actually references, so an + // auditor can see WHY without re-running with debug + // logging. Bounded — a typical predicate touches 2-5 + // namespaces. + let mut diagnostics = vec![format!("cel: {expr}")]; + diagnostics.extend(snapshot_referenced_bag_values(&program, bag)); + Ok(PdpDecision { + decision: Decision::Deny { + reason: Some("CEL expression evaluated to false".to_string()), + rule_source: "cel".to_string(), + }, + diagnostics, + }) + } + Ok(other) => Ok(self.on_error_decision(format!( + "CEL expression must return bool, got {other:?}" + ))), + Err(e) => { + // Eval errors are usually undeclared-variable typos. + // Enumerate the variables the expression references AND + // which ones the bag actually has, so the operator can + // see which name they meant. + let mut cause = format!("CEL eval error: {e}"); + let refs = program.references(); + let referenced: Vec<&str> = refs.variables(); + if !referenced.is_empty() { + let mut found = referenced + .iter() + .filter(|n| bag_namespace_present(bag, n)) + .copied() + .collect::>(); + found.sort_unstable(); + let mut missing = referenced + .iter() + .filter(|n| !bag_namespace_present(bag, n)) + .copied() + .collect::>(); + missing.sort_unstable(); + cause.push_str(&format!( + " (expr references variables: {referenced:?}; \ + present in bag: {found:?}; missing: {missing:?})" + )); + } + Ok(self.on_error_decision(cause)) + } + } + } +} + +/// Snapshot all bag entries whose dotted-key first segment matches any +/// of the top-level names the CEL expression references. Emits one +/// diagnostic string per matched key in `key=value` form. Used to +/// enrich Deny diagnostics so auditors can see what made the predicate +/// false without re-running with debug logging. +fn snapshot_referenced_bag_values( + program: &Program, + bag: &AttributeBag, +) -> Vec { + let refs = program.references(); + let referenced = refs.variables(); + if referenced.is_empty() { + return Vec::new(); + } + let referenced_set: std::collections::HashSet<&str> = + referenced.iter().copied().collect(); + + let mut snapshot: Vec = bag + .iter() + .filter(|(key, _)| { + let head = key.split('.').next().unwrap_or(key); + referenced_set.contains(head) + }) + .map(|(key, value)| format!("{key}={value:?}")) + .collect(); + snapshot.sort_unstable(); + snapshot +} + +/// Does the bag have any key whose dotted-prefix first segment matches +/// `name`? Used to classify referenced variables as present-or-missing +/// in eval-error diagnostics. +fn bag_namespace_present(bag: &AttributeBag, name: &str) -> bool { + bag.iter().any(|(key, _)| { + let head: &str = key.split('.').next().unwrap_or(key); + head == name + }) +} + +/// Read a string field from a YAML mapping (mirrors the cedar-direct helper). +fn read_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cel_call(expr: &str) -> PdpCall { + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("expr".into()), + serde_yaml::Value::String(expr.into()), + ); + PdpCall { + dialect: PdpDialect::Cel, + args: serde_yaml::Value::Mapping(m), + } + } + + fn bag_with(pairs: &[(&str, &str)]) -> AttributeBag { + let mut bag = AttributeBag::new(); + for (k, v) in pairs { + bag.set(*k, *v); + } + bag + } + + #[tokio::test] + async fn true_allows_false_denies() { + let r = CelResolver::new(); + let bag = bag_with(&[("subject.id", "alice")]); + + let allow = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + assert_eq!(allow.decision, Decision::Allow); + + let deny = r.evaluate(&cel_call("subject.id == 'bob'"), &bag).await.unwrap(); + assert!(matches!(deny.decision, Decision::Deny { .. })); + } + + #[tokio::test] + async fn missing_expr_is_dispatch_error() { + let r = CelResolver::new(); + let call = PdpCall { + dialect: PdpDialect::Cel, + args: serde_yaml::Value::Null, + }; + let err = r.evaluate(&call, &AttributeBag::new()).await.unwrap_err(); + assert!(matches!(err, PdpError::Dispatch(_))); + } + + /// A host can register a custom CEL function via `with_functions` + /// and expressions can call it. Registration composes: a second + /// `with_functions` call stacks on top of the first. + #[tokio::test] + async fn custom_function_registration_round_trips() { + let r = CelResolver::new() + .with_functions(|ctx| { + ctx.add_function( + "double", + |n: i64| -> i64 { n * 2 }, + ); + }) + .with_functions(|ctx| { + ctx.add_function( + "shout", + |s: Arc| -> String { s.to_uppercase() }, + ); + }); + let bag = bag_with(&[("subject.id", "alice")]); + + // First registered function works. + let out = r.evaluate(&cel_call("double(21) == 42"), &bag).await.unwrap(); + assert_eq!( + out.decision, Decision::Allow, + "first registered function must be callable", + ); + + // Second composed function works (and reads a bag value). + let out = r + .evaluate(&cel_call("shout(subject.id) == 'ALICE'"), &bag) + .await + .unwrap(); + assert_eq!( + out.decision, Decision::Allow, + "subsequent with_functions calls must compose, not replace", + ); + } + + /// The `regex` cel-feature is explicitly enabled in our Cargo.toml. + /// Pin that `matches(s, pattern)` actually works through the + /// resolver so a future feature-set churn breaks loudly here. + #[tokio::test] + async fn matches_regex_function_is_available() { + let r = CelResolver::new(); + let bag = bag_with(&[("args.path", "/api/v1/tools/call")]); + let out = r + .evaluate(&cel_call("args.path.matches('^/api/v[0-9]+/')"), &bag) + .await + .unwrap(); + assert_eq!( + out.decision, Decision::Allow, + "the regex CEL feature must be enabled so authors can match paths", + ); + } + + #[tokio::test] + async fn undeclared_variable_fails_closed_by_default() { + let r = CelResolver::new(); + // `nonexistent` is not in the bag → eval error → fail-closed Deny. + let out = r.evaluate(&cel_call("nonexistent.field == 1"), &AttributeBag::new()).await.unwrap(); + assert!(matches!(out.decision, Decision::Deny { .. })); + } + + /// On Deny, diagnostics include a snapshot of the bag values for + /// every top-level namespace the expression references. Auditors + /// reading the diagnostics see WHY the predicate evaluated false + /// without re-running with debug logging. + #[tokio::test] + async fn deny_diagnostics_snapshot_referenced_bag_values() { + let r = CelResolver::new(); + let bag = bag_with(&[ + ("subject.id", "eve"), + ("subject.type", "user"), + ("unrelated.key", "ignore-me"), + ]); + let out = r + .evaluate(&cel_call("subject.id == 'alice'"), &bag) + .await + .unwrap(); + assert!(matches!(out.decision, Decision::Deny { .. })); + let snapshot = out + .diagnostics + .iter() + .find(|d| d.contains("subject.id=")) + .unwrap_or_else(|| panic!("expected subject.id snapshot; got {:?}", out.diagnostics)); + assert!( + snapshot.contains("\"eve\""), + "snapshot must carry the actual bag value; got {snapshot:?}", + ); + // Unrelated namespaces stay out — keeps the diagnostic bounded. + assert!( + !out.diagnostics.iter().any(|d| d.contains("unrelated")), + "snapshot must be scoped to referenced namespaces; got {:?}", + out.diagnostics, + ); + } + + /// On an eval error (undeclared variable), the cause string lists + /// the referenced variables AND classifies them present-vs-missing + /// in the bag, so the operator can see which typo they made. + #[tokio::test] + async fn eval_error_diagnostics_classify_referenced_variables() { + let r = CelResolver::new(); + let bag = bag_with(&[("subject.id", "alice")]); + // `subjcet` is a typo for `subject` — eval error, fail-closed. + let out = r + .evaluate(&cel_call("subjcet.id == 'alice'"), &bag) + .await + .unwrap(); + let cause = match out.decision { + Decision::Deny { reason, .. } => reason.unwrap_or_default(), + other => panic!("expected Deny; got {other:?}"), + }; + assert!( + cause.contains("missing: [\"subjcet\"]"), + "cause must classify the typo as missing; got {cause:?}", + ); + } + + /// A malformed `expr` always Denies, even with `on_error: allow`. + /// Compile errors are author bugs — silently flipping them to Allow + /// would let broken policy bypass the gate. Pins the asymmetry + /// between compile errors and runtime errors. + #[tokio::test] + async fn compile_error_always_denies_even_with_on_error_allow() { + let r = CelResolver::new().with_on_error(OnError::Allow); + // `1 +` is a syntax error → compile failure → unconditional Deny. + let out = r.evaluate(&cel_call("1 +"), &AttributeBag::new()).await.unwrap(); + match out.decision { + Decision::Deny { reason, rule_source } => { + assert_eq!(rule_source, "cel"); + let r = reason.unwrap_or_default(); + assert!( + r.contains("compile error"), + "deny reason must name the compile failure; got {r:?}", + ); + } + other => panic!("compile error must deny regardless of on_error; got {other:?}"), + } + } + + #[tokio::test] + async fn on_error_allow_flips_eval_error_to_allow() { + let r = CelResolver::new().with_on_error(OnError::Allow); + let out = r.evaluate(&cel_call("nonexistent.field == 1"), &AttributeBag::new()).await.unwrap(); + assert_eq!(out.decision, Decision::Allow); + } + + #[tokio::test] + async fn non_boolean_result_fails_closed() { + let r = CelResolver::new(); + let bag = bag_with(&[("subject.id", "alice")]); + // Returns a string, not a bool → degenerate → fail-closed Deny. + let out = r.evaluate(&cel_call("subject.id"), &bag).await.unwrap(); + assert!(matches!(out.decision, Decision::Deny { .. })); + } + + #[tokio::test] + async fn compile_cache_reuses_program() { + let r = CelResolver::new(); + let bag = bag_with(&[("subject.id", "alice")]); + let expr = "subject.id == 'alice'"; + let _ = r.evaluate(&cel_call(expr), &bag).await.unwrap(); + let _ = r.evaluate(&cel_call(expr), &bag).await.unwrap(); + // One distinct expr → exactly one cached program (compiled once). + let cache = r.cache.read().unwrap(); + assert_eq!(cache.len(), 1); + assert!(cache.contains_key(expr)); + } + + /// At the cache cap, the *next new* expr is rejected — but already- + /// cached exprs still evaluate normally. The rejected call is routed + /// through `on_error` (default Deny), so policy still gets a + /// decision even when the operator's cap is too tight. + #[tokio::test] + async fn cache_cap_rejects_new_exprs_but_keeps_old_ones() { + let r = CelResolver::new().with_max_cache_entries(1); + let bag = bag_with(&[("subject.id", "alice")]); + + // First expr fills the cache. + let first = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + assert_eq!(first.decision, Decision::Allow); + assert_eq!(r.cache.read().unwrap().len(), 1); + + // Second distinct expr → rejected by the cap → on_error Deny. + let second = r.evaluate(&cel_call("subject.id != ''"), &bag).await.unwrap(); + assert!( + matches!(second.decision, Decision::Deny { .. }), + "cap rejection must route through on_error Deny by default", + ); + assert!( + second.diagnostics.iter().any(|d| d.contains("cache full")), + "rejection diagnostic must name the cause; got {:?}", + second.diagnostics, + ); + assert_eq!( + r.cache.read().unwrap().len(), + 1, + "rejected expr must not be inserted", + ); + + // Cached expr still works. + let third = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + assert_eq!(third.decision, Decision::Allow); + } + + /// `on_error: allow` flips a cache-full rejection to Allow — same + /// path as compile / eval errors. Pins that the fail-open knob is + /// uniform across all degenerate outcomes. + #[tokio::test] + async fn cache_cap_respects_on_error_allow() { + let r = CelResolver::new() + .with_max_cache_entries(1) + .with_on_error(OnError::Allow); + let bag = bag_with(&[("subject.id", "alice")]); + + // Fill the cache. + let _ = r.evaluate(&cel_call("subject.id == 'alice'"), &bag).await.unwrap(); + + // Second distinct expr is cap-rejected → on_error Allow. + let out = r.evaluate(&cel_call("subject.id != ''"), &bag).await.unwrap(); + assert_eq!(out.decision, Decision::Allow); + } + + #[test] + fn from_config_parses_on_error() { + let yaml: serde_yaml::Value = + serde_yaml::from_str("kind: cel\non_error: allow\n").unwrap(); + let r = CelResolver::from_config(&yaml).unwrap(); + assert_eq!(r.on_error, OnError::Allow); + } + + #[test] + fn from_config_rejects_bad_on_error() { + let yaml: serde_yaml::Value = + serde_yaml::from_str("kind: cel\non_error: maybe\n").unwrap(); + assert!(matches!( + CelResolver::from_config(&yaml), + Err(BuildError::ConfigShape(_)) + )); + } + + /// An unknown config key (here `on_errr`, a typo for `on_error`) is + /// rejected at config-parse time rather than silently dropped — a + /// dropped key would mask the typo and use the default `Deny`, + /// leaving the operator believing they'd set `allow`. The error + /// names the offending key. + #[test] + fn from_config_rejects_unknown_key() { + let yaml: serde_yaml::Value = + serde_yaml::from_str("kind: cel\non_errr: allow\n").unwrap(); + match CelResolver::from_config(&yaml) { + Err(BuildError::ConfigShape(msg)) => assert!( + msg.contains("on_errr"), + "error must name the unknown key; got {msg:?}", + ), + Ok(_) => panic!("unknown key `on_errr` must be rejected"), + } + } + + /// Many threads evaluating the same expression on one shared + /// resolver must all get the right decision, and the compile cache + /// must hold exactly one entry (the `RwLock` read path is + /// uncontended in steady state; this pins that concurrent reads + /// don't double-insert or deadlock). + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_evaluation_shares_one_cached_program() { + let resolver = Arc::new(CelResolver::new()); + let expr = "subject.id == 'alice'"; + + let tasks: Vec<_> = (0..64) + .map(|i| { + let r = Arc::clone(&resolver); + tokio::spawn(async move { + // Half match, half don't — exercises both Allow and + // Deny through the shared cache concurrently. + let id = if i % 2 == 0 { "alice" } else { "bob" }; + let bag = bag_with(&[("subject.id", id)]); + let out = r.evaluate(&cel_call(expr), &bag).await.unwrap(); + (id, out.decision) + }) + }) + .collect(); + + for task in tasks { + let (id, decision) = task.await.unwrap(); + if id == "alice" { + assert_eq!(decision, Decision::Allow); + } else { + assert!(matches!(decision, Decision::Deny { .. })); + } + } + + // One distinct expr → exactly one compiled program despite the + // concurrent first-miss race. + let cache = resolver.cache.read().unwrap(); + assert_eq!(cache.len(), 1, "concurrent compiles must converge to one entry"); + assert!(cache.contains_key(expr)); + } +} diff --git a/crates/apl-pdp-cel/tests/visitor_cel_config.rs b/crates/apl-pdp-cel/tests/visitor_cel_config.rs new file mode 100644 index 00000000..57eeb4d5 --- /dev/null +++ b/crates/apl-pdp-cel/tests/visitor_cel_config.rs @@ -0,0 +1,320 @@ +// Location: ./crates/apl-pdp-cel/tests/visitor_cel_config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end integration: a unified-config YAML that +// +// 1. declares a `cel` PDP under `global.apl.pdp[]`, +// 2. attaches a `cel:(expr: "...")` policy step to a route, +// +// must flow a real decision from the cpex-core dispatcher through +// `AplConfigVisitor` → `PdpFactory` → `CelResolver` → the `cel` +// interpreter → back into the route handler's allow/deny split. +// +// This proves the *wiring* end-to-end. The crate's unit tests cover the +// bag→activation mapping and the resolver in isolation; what's special +// here is that the resolver was never instantiated in Rust by the test — +// the visitor built it from YAML at `load_config_yaml` time because the +// host registered `CelPdpFactory` via `AplOptions.pdp_factories`. If this +// passes, an operator who drops a `cel` block into their config gets the +// same behavior without writing any glue. + +use std::collections::HashSet; +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::{MetaExtension, SecurityExtension, SubjectExtension, SubjectType}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::manager::PluginManager; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; +use apl_pdp_cel::CelPdpFactory; + +// The config the visitor walks. A `cel:` step whose expression reads the +// common attribute vocabulary (`subject.id`, `role.*`) the cmf BagBuilder +// lifts from the SecurityExtension. `has(role.reader)` guards the optional +// role namespace so a principal with no roles evaluates to a clean `false` +// (Deny) rather than an undeclared-variable error. +const YAML: &str = r#" +global: + apl: + pdp: + - kind: cel +routes: + - tool: get_document + apl: + policy: + - cel: + expr: | + subject.id == "alice" && has(role.reader) && role.reader +"#; + +fn meta_for_tool(name: &str) -> MetaExtension { + MetaExtension { + entity_type: Some("tool".to_string()), + entity_name: Some(name.to_string()), + ..Default::default() + } +} + +fn security_with_roles(id: &str, roles: &[&str]) -> SecurityExtension { + SecurityExtension { + subject: Some(SubjectExtension { + id: Some(id.to_string()), + subject_type: Some(SubjectType::User), + roles: roles.iter().map(|r| r.to_string()).collect::>(), + ..Default::default() + }), + ..Default::default() + } +} + +async fn build_manager() -> Arc { + build_manager_with_yaml(YAML) + .await + .expect("load_config_yaml") +} + +/// Build a manager from arbitrary YAML; returns the load error so +/// negative tests can inspect it. Mirrors `build_manager` but lets +/// tests swap the config text under test. +async fn build_manager_with_yaml( + yaml: &str, +) -> Result, Box> { + let mgr = Arc::new(PluginManager::default()); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + // The factory is the load-bearing wiring under test: the visitor + // sees `kind: cel` in YAML and finds this factory by key. + pdp_factories: vec![Arc::new(CelPdpFactory::new())], + base_capabilities: None, + }, + ); + mgr.load_config_yaml(yaml).map_err(|e| -> Box { + format!("{e}").into() + })?; + mgr.initialize().await.map_err(|e| -> Box { + format!("{e}").into() + })?; + Ok(mgr) +} + +fn payload() -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, "fetch doc-42"), + } +} + +/// `alice` with `role.reader=true` satisfies the CEL predicate → Allow. +/// End-to-end: visitor built the resolver from YAML, route handler +/// dispatched the `cel:` step into it, CEL returned `true`, pipeline +/// continues. +#[tokio::test] +async fn config_declared_cel_pdp_allows_matching_subject() { + let mgr = build_manager().await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &["reader"]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + result.continue_processing, + "alice+reader should satisfy the CEL predicate; got violation = {:?}", + result.violation + ); +} + +/// `eve` is not `alice` → the CEL predicate is `false` → Deny halts the +/// pipeline. (Short-circuit `&&` means the missing `role` namespace is +/// never touched.) +#[tokio::test] +async fn config_declared_cel_pdp_denies_non_matching_subject() { + let mgr = build_manager().await; + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("eve", &["reader"]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + !result.continue_processing, + "eve should fail the subject.id check and be denied", + ); + assert!( + result.violation.is_some(), + "deny path must surface a violation", + ); +} + +/// A malformed CEL PDP config (`on_error: maybe`) must be rejected at +/// `load_config_yaml` rather than discovered on first request. The +/// visitor → `CelPdpFactory::build` → `CelResolver::from_config` chain +/// surfaces `BuildError::ConfigShape` as a `cpex_core::PluginError`, +/// which bubbles out of load. +#[tokio::test] +async fn malformed_on_error_is_rejected_at_load() { + const BAD_YAML: &str = r#" +global: + apl: + pdp: + - kind: cel + on_error: maybe +routes: + - tool: get_document + apl: + policy: + - cel: + expr: | + subject.id == "alice" +"#; + let err = match build_manager_with_yaml(BAD_YAML).await { + Ok(_) => panic!("malformed on_error must fail load_config_yaml"), + Err(e) => e, + }; + let msg = format!("{err}"); + assert!( + msg.contains("on_error") && msg.contains("maybe"), + "load error should name the bad field and value; got: {msg}", + ); +} + +/// `on_error: allow` at the config level flips an eval error (here, an +/// undeclared-variable reference) to Allow end-to-end. Pins the +/// fail-open knob travels from YAML → factory → resolver → router → +/// route-handler decision the same way as the unit-level resolver test. +#[tokio::test] +async fn on_error_allow_yaml_flips_eval_error_to_allow_end_to_end() { + const ALLOW_YAML: &str = r#" +global: + apl: + pdp: + - kind: cel + on_error: allow +routes: + - tool: get_document + apl: + policy: + - cel: + expr: | + nonexistent.field == "value" +"#; + let mgr = build_manager_with_yaml(ALLOW_YAML) + .await + .expect("on_error: allow config must load cleanly"); + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &["reader"]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + result.continue_processing, + "eval error under on_error=allow must surface as Allow; got violation = {:?}", + result.violation, + ); +} + +/// A `cel:` step with no `expr` (the author wrote reactions but forgot +/// the predicate) is an author bug that the parser accepts opaquely — +/// the resolver only learns of it at request time. It must surface as a +/// clean Deny ("PDP error") that halts the pipeline, never a panic. +/// Complements the unit-level `missing_expr_is_dispatch_error` by +/// proving the error travels through the real dispatcher. +#[tokio::test] +async fn missing_expr_at_request_time_denies_without_panicking() { + const NO_EXPR_YAML: &str = r#" +global: + apl: + pdp: + - kind: cel +routes: + - tool: get_document + apl: + policy: + - cel: + on_deny: + - deny +"#; + let mgr = build_manager_with_yaml(NO_EXPR_YAML) + .await + .expect("a cel step without expr is accepted at parse/load time"); + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &["reader"]))), + ..Default::default() + }; + + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), ext, None) + .await; + + assert!( + !result.continue_processing, + "a missing-expr cel step must halt the pipeline, not allow through", + ); + assert!( + result.violation.is_some(), + "missing-expr dispatch error must surface as a violation", + ); +} + +/// A `cel:` predicate that reads the `meta` namespace +/// (`meta.entity_name`) proves the cmf BagBuilder lifts `MetaExtension` +/// into the bag and the activation exposes it to CEL — the other +/// integration cases only exercise `subject.*` / `role.*` from the +/// SecurityExtension. Gates the tool by name end-to-end. +#[tokio::test] +async fn cel_reads_meta_entity_name_from_bag() { + const META_YAML: &str = r#" +global: + apl: + pdp: + - kind: cel +routes: + - tool: get_document + apl: + policy: + - cel: + expr: | + meta.entity_name == "get_document" +"#; + let mgr = build_manager_with_yaml(META_YAML) + .await + .expect("load_config_yaml"); + + // Matching tool name → predicate true → Allow. + let allow_ext = Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &["reader"]))), + ..Default::default() + }; + let (allow, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", payload(), allow_ext, None) + .await; + assert!( + allow.continue_processing, + "meta.entity_name == \"get_document\" must reach CEL and allow; got violation = {:?}", + allow.violation, + ); +}