Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions builtins/pdps/cel/src/activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,8 @@
// 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.
// If a key is both a leaf and a namespace prefix, the namespace wins
// and the scalar is dropped with a warning.

use std::collections::{BTreeMap, HashMap};

Expand Down Expand Up @@ -232,6 +229,11 @@ mod tests {
matches!(run_cel(expr, &ctx), Ok(Value::Bool(true)))
}

fn insert_key(root: &mut BTreeMap<String, Node>, key: &str, leaf: Value) {
let segments: Vec<&str> = key.split('.').collect();
insert(root, key, &segments, leaf);
}

#[test]
fn dotted_keys_become_nested_maps() {
let mut bag = AttributeBag::new();
Expand Down Expand Up @@ -381,4 +383,50 @@ mod tests {
bag.set("delegation.depth", 3_i64);
assert!(truthy("delegation.depth == 3", &bag));
}

#[test]
fn namespace_wins_when_scalar_arrives_after_branch() {
let mut root = BTreeMap::new();
insert_key(&mut root, "delegation.depth", Value::from(3_i64));
insert_key(
&mut root,
"delegation",
Value::from("scalar-value".to_owned()),
);

assert!(
matches!(
root.get("delegation"),
Some(Node::Branch(children))
if matches!(
children.get("depth"),
Some(Node::Leaf(Value::Int(3)))
)
),
"namespace must retain its child",
);
}

#[test]
fn namespace_wins_when_branch_arrives_after_scalar() {
let mut root = BTreeMap::new();
insert_key(
&mut root,
"delegation",
Value::from("scalar-value".to_owned()),
);
insert_key(&mut root, "delegation.depth", Value::from(3_i64));

assert!(
matches!(
root.get("delegation"),
Some(Node::Branch(children))
if matches!(
children.get("depth"),
Some(Node::Leaf(Value::Int(3)))
)
),
"namespace must replace the scalar",
);
}
}
46 changes: 46 additions & 0 deletions builtins/pdps/cel/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ impl CelResolver {
///
/// Composes: calling `with_functions` more than once stacks the
/// callbacks. Each runs in registration order on every context.
/// Later registrations replace earlier custom functions with the same name.
/// Standard-library overloads take precedence; `has` is parser-reserved.
///
/// # Example
///
Expand Down Expand Up @@ -671,6 +673,50 @@ mod tests {
);
}

#[tokio::test]
async fn custom_size_stdlib_overload_then_int_fallback() {
let r = CelResolver::new().with_functions(|ctx| {
ctx.add_function("size", |_n: i64| -> i64 { 777 });
});
let bag = bag_with(&[("subject.id", "alice")]);

let stdlib = r
.evaluate(&cel_call("size('hello') == 5"), &bag)
.await
.unwrap();
assert_eq!(stdlib.decision, Decision::Allow);

let fallback = r
.evaluate(&cel_call("size(42) == 777"), &bag)
.await
.unwrap();
assert_eq!(fallback.decision, Decision::Allow);
}

#[tokio::test]
async fn later_custom_function_overwrites_earlier_one() {
let r = CelResolver::new()
.with_functions(|ctx| {
ctx.add_function("magic", |n: i64| -> i64 { n + 100 });
})
.with_functions(|ctx| {
ctx.add_function("magic", |n: i64| -> i64 { n + 999 });
});
let bag = bag_with(&[("subject.id", "alice")]);

let out = r
.evaluate(&cel_call("magic(1) == 1000"), &bag)
.await
.unwrap();
assert_eq!(out.decision, Decision::Allow);

let out = r
.evaluate(&cel_call("magic(1) == 101"), &bag)
.await
.unwrap();
assert!(matches!(out.decision, Decision::Deny { .. }));
}

/// 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.
Expand Down