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
72 changes: 72 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 32 additions & 3 deletions crates/apl-core/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,10 +566,10 @@ fn parse_require_rule(line: &str) -> Result<Expression, ParseError> {
})
}

/// 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(':'));
}
Expand Down Expand Up @@ -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"
)
}

Expand Down Expand Up @@ -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:`
Expand Down
47 changes: 45 additions & 2 deletions crates/apl-core/src/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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])`
//
Expand Down Expand Up @@ -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),
}
Expand All @@ -178,6 +187,7 @@ impl PdpDialect {
"opa" => Self::Opa,
"authzen" => Self::AuthZen,
"nemo" => Self::NeMo,
"cel" => Self::Cel,
other => Self::Custom(other.to_string()),
}
}
Expand Down Expand Up @@ -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);
}
}
16 changes: 14 additions & 2 deletions crates/apl-cpex/src/pdp_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions crates/apl-pdp-cel/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
Loading