From 3d21d7dd00c4c1fe30f21cee65a0b4fa00abf4f1 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 00:15:31 -0400 Subject: [PATCH 01/10] =?UTF-8?q?feat(pdp-opa):=20scaffold=20embedded=20OP?= =?UTF-8?q?A/Rego=20PDP=20crate=20and=20bag=E2=86=92input=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the cpex-pdp-opa crate backed by the pure-Rust regorus interpreter and wire it into the workspace. regorus is pinned with default-features disabled and an explicit builtin allow-list that excludes http, net, opa-runtime, and jsonschema, keeping the embedded policy surface free of network egress and runtime introspection. Implements the AttributeBag -> Rego input mapping (flat dotted keys to a nested JSON document, StringSet to sorted array, whole-number floats to integers, namespace-wins on leaf/namespace collisions), plus a contract test pinning the regorus API the resolver depends on. --- Cargo.lock | 225 ++++++++++++++++++++++++++++-- Cargo.toml | 3 + builtins/pdps/opa/Cargo.toml | 81 +++++++++++ builtins/pdps/opa/src/input.rs | 245 +++++++++++++++++++++++++++++++++ builtins/pdps/opa/src/lib.rs | 170 +++++++++++++++++++++++ 5 files changed, 715 insertions(+), 9 deletions(-) create mode 100644 builtins/pdps/opa/Cargo.toml create mode 100644 builtins/pdps/opa/src/input.rs create mode 100644 builtins/pdps/opa/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9a49b7d6..4f3bea08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,6 +516,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -637,6 +647,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -651,6 +672,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "chunked_transfer" version = "1.5.0" @@ -666,6 +697,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colored" version = "3.1.1" @@ -838,6 +878,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "cpex-pdp-opa" +version = "0.2.2" +dependencies = [ + "apl-cmf", + "apl-core", + "apl-cpex", + "async-trait", + "cpex-core", + "regorus", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "cpex-plugin-audit-logger" version = "0.2.2" @@ -992,6 +1049,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1027,7 +1093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -1081,6 +1147,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deadpool" version = "0.13.0" @@ -1274,6 +1346,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "ena" version = "0.14.4" @@ -1567,6 +1651,19 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -2032,7 +2129,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2073,7 +2170,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2165,6 +2262,12 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" + [[package]] name = "lru-slab" version = "0.1.2" @@ -2254,6 +2357,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "msvc_spectre_libs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e871a9861f3664f18b7e04e9301d4edd55090c2dadb4b1c602e26ab32b1f5b" +dependencies = [ + "cc", +] + [[package]] name = "murmur3" version = "0.4.1" @@ -2294,7 +2406,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-complex", "num-integer", "num-iter", @@ -2312,6 +2424,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2369,7 +2491,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", ] @@ -2528,6 +2650,15 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -2537,6 +2668,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -2606,6 +2746,18 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2930,6 +3082,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2968,6 +3131,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redis" version = "1.3.0" @@ -3055,6 +3224,37 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regorus" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cc4dc91481b1d4001ba7f2e81f7faf674142e0ac36d37d79e5f02764d06571e" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz", + "data-encoding", + "globset", + "indexmap 2.14.0", + "lazy_static", + "lru", + "msvc_spectre_libs", + "num-bigint 0.5.1", + "num-traits", + "parking_lot", + "postcard", + "rand 0.10.2", + "regex", + "semver", + "serde", + "serde_json", + "serde_yaml", + "spin 0.12.2", + "thiserror 2.0.18", + "url", + "uuid", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3491,7 +3691,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3503,7 +3703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3555,7 +3755,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "thiserror 2.0.18", "time", @@ -3605,6 +3805,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" + [[package]] name = "spki" version = "0.6.0" @@ -3651,7 +3857,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] @@ -4221,6 +4427,7 @@ checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", + "rand 0.10.2", "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 11f2bd51..5031b8a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "builtins/plugins/elicitation-ciba", "builtins/pdps/cedar-direct", "builtins/pdps/cel", + "builtins/pdps/opa", "builtins/session/valkey", "examples/go-demo/ffi", "examples/tutorial", @@ -61,6 +62,7 @@ default-members = [ "builtins/plugins/elicitation-ciba", "builtins/pdps/cedar-direct", "builtins/pdps/cel", + "builtins/pdps/opa", "examples/go-demo/ffi", "examples/tutorial", ] @@ -129,6 +131,7 @@ cpex-plugin-delegator-biscuit = { path = "builtins/plugins/delegator-biscuit", v cpex-plugin-elicitation-ciba = { path = "builtins/plugins/elicitation-ciba", version = "0.2.2" } cpex-pdp-cedar-direct = { path = "builtins/pdps/cedar-direct", version = "0.2.2" } cpex-pdp-cel = { path = "builtins/pdps/cel", version = "0.2.2" } +cpex-pdp-opa = { path = "builtins/pdps/opa", version = "0.2.2" } cpex-session-valkey = { path = "builtins/session/valkey", version = "0.2.2" } # Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked diff --git a/builtins/pdps/opa/Cargo.toml b/builtins/pdps/opa/Cargo.toml new file mode 100644 index 00000000..1eba4c75 --- /dev/null +++ b/builtins/pdps/opa/Cargo.toml @@ -0,0 +1,81 @@ +# Location: ./builtins/pdps/opa/Cargo.toml +# Copyright 2026 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Fred Araujo +# +# cpex-pdp-opa — a `PdpResolver` that evaluates OPA/Rego policy in-process via +# the pure-Rust `regorus` interpreter. Global and/or inline Rego modules plus +# external `data`; the request `AttributeBag` maps to the Rego `input` +# document; the configured query resolves to allow/deny. +# +# See the crate-level module docs in `src/lib.rs` for the full picture: where +# it sits in the stack, the bag→input mapping, the decision contract, and the +# base-engine / clone-per-request evaluation model. + +[package] +name = "cpex-pdp-opa" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "CPEX PDP — embedded OPA/Rego policy evaluation via regorus." +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true + +[dependencies] +apl-core = { workspace = true } +# regorus — Microsoft's pure-Rust Rego interpreter (Apache-2.0). +# +# `default-features = false` is deliberate. regorus's `default` +# (`arc + full-opa + rvm`) bundles the `http`, `net`, `opa-runtime`, and +# `jsonschema` builtins. Inline Rego is authored by a less-trusted actor than +# the operator, so this crate opts out of that surface and enables only the +# pure, local builtins an authorization predicate needs — the same discipline +# `cpex-pdp-cel` applies when it pins CEL features explicitly. Excluded on +# purpose: +# - http → `http.send` network egress +# - net → ipnet/CIDR builtins +# - opa-runtime → `opa.runtime()` environment/config introspection +# - jsonschema → large dependency, unused for allow/deny predicates +# `arc` is load-bearing: it switches regorus's internal reference counting to +# atomic `Arc` so `Engine` is `Send`/`Sync` and the resolver can be shared +# across worker threads behind `Arc`. +regorus = { version = "0.11", default-features = false, features = [ + "arc", + "std", + "rvm", + "regex", + "time", + "base64", + "base64url", + "hex", + "glob", + "graph", + "semver", + "urlquery", + "uuid", + "yaml", + "cache", + "coverage", +] } +serde_json = { workspace = true } +async-trait = { workspace = true } +serde_yaml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# End-to-end integration tests wire the opa 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. +apl-cmf = { workspace = true } +apl-cpex = { workspace = true } +cpex-core = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/builtins/pdps/opa/src/input.rs b/builtins/pdps/opa/src/input.rs new file mode 100644 index 00000000..3829996e --- /dev/null +++ b/builtins/pdps/opa/src/input.rs @@ -0,0 +1,245 @@ +// Location: ./builtins/pdps/opa/src/input.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Bag → Rego `input` mapping. +// +// APL's `AttributeBag` is a flat `HashMap` with dotted +// keys (`subject.id`, `role.hr`, `delegation.depth`). Rego wants a nested +// document so `input.subject.id` reads as field selection. This module rebuilds +// the flat bag into a nested JSON object that becomes the engine's `input`. +// +// This mirrors the tree-building and type coercions in `cpex-pdp-cel`'s +// `activation.rs` so a policy author's mental model of the attribute +// vocabulary is identical across the two backends. It is ported rather than +// shared so this crate stays apl-core-only at compile time. +// +// Type mapping (`AttributeValue` → JSON): +// Bool → bool +// Int → integer +// Float → integer when it is a whole number in i64 range, else float +// String → string +// StringSet → array of strings, sorted (so `input.x[0]` is deterministic) +// +// Collision rule: if a key is both a leaf and a namespace prefix (`delegation` +// AND `delegation.depth`), the namespace (object) wins and the scalar leaf is +// dropped with a `tracing::warn!`, matching the CEL resolver. + +use std::collections::BTreeMap; + +use apl_core::attributes::{AttributeBag, AttributeValue}; +use serde_json::{Map, Number, Value}; + +/// Build the Rego `input` document from the policy bag. +/// +/// Every dotted bag key becomes a nested field: `subject.id` → `{"subject": +/// {"id": ...}}`. Single-segment keys (`authenticated`) become top-level +/// fields. The result is always a JSON object (an empty bag yields `{}`). +pub fn bag_to_input(bag: &AttributeBag) -> Value { + 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)); + } + node_map_to_value(root) +} + +/// Internal tree node: either a leaf scalar/array or a nested namespace. +enum Node { + Leaf(Value), + Branch(BTreeMap), +} + +/// 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 return cleanly rather + // than panic if a future bag implementation emits one. + let Some((head, rest)) = segments.split_first() else { + return; + }; + let head = (*head).to_string(); + + if rest.is_empty() { + match level.get(&head) { + Some(Node::Branch(_)) => { + tracing::warn!( + key = %full_key, + "OPA input: scalar key collides with an existing namespace; \ + keeping the namespace and dropping the scalar" + ); + }, + _ => { + level.insert(head, Node::Leaf(leaf)); + }, + } + return; + } + + let entry = level + .entry(head) + .or_insert_with(|| Node::Branch(BTreeMap::new())); + if let Node::Leaf(_) = entry { + tracing::warn!( + key = %full_key, + "OPA input: 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 of nodes into a JSON object value. +fn node_map_to_value(children: BTreeMap) -> Value { + let mut map = Map::new(); + for (k, child) in children { + map.insert(k, node_to_value(child)); + } + Value::Object(map) +} + +fn node_to_value(node: Node) -> Value { + match node { + Node::Leaf(v) => v, + Node::Branch(children) => node_map_to_value(children), + } +} + +/// Convert one `AttributeValue` to a JSON value. +fn attr_to_value(attr: &AttributeValue) -> Value { + match attr { + AttributeValue::Bool(b) => Value::Bool(*b), + AttributeValue::Int(i) => Value::Number((*i).into()), + AttributeValue::Float(f) => float_to_value(*f), + AttributeValue::String(s) => Value::String(s.clone()), + // StringSet → sorted array. Rego treats the value as a JSON array; + // sorting makes any index-dependent policy deterministic across runs. + AttributeValue::StringSet(set) => { + let mut sorted: Vec<&String> = set.iter().collect(); + sorted.sort(); + Value::Array( + sorted + .into_iter() + .map(|s| Value::String(s.clone())) + .collect(), + ) + }, + } +} + +/// Yield an `f64` as a JSON integer when it is a whole number in `i64` range, +/// otherwise a JSON float. Keeps parity with CEL's `float_to_value` so a bag +/// value populated as `Float(2.0)` reads as `2` for an author. A non-finite +/// float has no JSON representation and becomes `null`. +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::Number((f as i64).into()) + } else { + Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + use regorus::Engine; + + /// Evaluate a boolean Rego expression against an input built from `bag`. + /// Wraps the expression in a rule so we exercise the exact input path the + /// resolver uses (`set_input_json` + `eval_rule`). + fn rego_eval(expr: &str, bag: &AttributeBag) -> bool { + let input = bag_to_input(bag); + let mut engine = Engine::new(); + engine + .add_policy( + "t.rego".to_string(), + format!("package t\nresult if {{ {expr} }}\n"), + ) + .unwrap(); + engine.set_input_json(&input.to_string()).unwrap(); + engine + .eval_rule("data.t.result".to_string()) + .unwrap() + .as_bool() + .copied() + .unwrap_or(false) + } + + #[test] + fn dotted_keys_become_nested_fields() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "alice"); + bag.set("subject.type", "user"); + assert!(rego_eval("input.subject.id == \"alice\"", &bag)); + assert!(rego_eval("input.subject.type == \"user\"", &bag)); + } + + #[test] + fn single_segment_key_is_top_level_field() { + let mut bag = AttributeBag::new(); + bag.set("authenticated", true); + assert!(rego_eval("input.authenticated == true", &bag)); + } + + #[test] + fn bool_int_float_string_scalars() { + let mut bag = AttributeBag::new(); + bag.set("role.hr", true); + bag.set("delegation.depth", 2_i64); + bag.set("intent.confidence", 0.92_f64); + bag.set("subject.id", "alice"); + assert!(rego_eval("input.role.hr == true", &bag)); + assert!(rego_eval("input.delegation.depth == 2", &bag)); + assert!(rego_eval("input.intent.confidence > 0.9", &bag)); + assert!(rego_eval("input.subject.id == \"alice\"", &bag)); + } + + #[test] + fn whole_number_float_reads_as_integer() { + let mut bag = AttributeBag::new(); + bag.set("delegation.depth", 2.0_f64); + // Emitted as the JSON integer 2 (not 2.0), so an author comparing to an + // integer literal gets the natural result. + assert_eq!( + bag_to_input(&bag)["delegation"]["depth"], + serde_json::json!(2) + ); + assert!(rego_eval("input.delegation.depth == 2", &bag)); + } + + #[test] + fn string_set_becomes_sorted_array() { + let mut bag = AttributeBag::new(); + bag.set( + "session.labels", + HashSet::from(["zeta".to_string(), "alpha".to_string(), "mu".to_string()]), + ); + assert!(rego_eval("\"alpha\" in input.session.labels", &bag)); + assert!(rego_eval("input.session.labels[0] == \"alpha\"", &bag)); + assert!(rego_eval("input.session.labels[2] == \"zeta\"", &bag)); + } + + #[test] + fn namespace_wins_on_leaf_collision() { + let mut bag = AttributeBag::new(); + bag.set("delegation", "scalar-value"); + bag.set("delegation.depth", 3_i64); + // The namespace must win so `delegation.depth` resolves rather than the + // scalar shadowing it. + assert!(rego_eval("input.delegation.depth == 3", &bag)); + } + + #[test] + fn empty_bag_is_empty_object() { + let bag = AttributeBag::new(); + assert_eq!(bag_to_input(&bag), serde_json::json!({})); + } +} diff --git a/builtins/pdps/opa/src/lib.rs b/builtins/pdps/opa/src/lib.rs new file mode 100644 index 00000000..1fc34a9a --- /dev/null +++ b/builtins/pdps/opa/src/lib.rs @@ -0,0 +1,170 @@ +// Location: ./builtins/pdps/opa/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// cpex-pdp-opa — `PdpResolver` over the pure-Rust `regorus` Rego interpreter. +// +// (Full crate docs land with the resolver in U3/U4.) + +pub mod input; + +#[cfg(test)] +mod regorus_api_smoke { + // Pins the exact regorus 0.11 API the resolver is built on: incremental + // module load, JSON input, query eval, and the Value shapes for allow / + // deny-set / undefined. If a regorus upgrade changes any of these, this + // fails loudly here rather than deep in the resolver. + use regorus::Engine; + + #[test] + fn bool_query_allow_and_deny() { + let mut engine = Engine::new(); + engine + .add_policy( + "authz.rego".to_string(), + r#"package authz +default allow := false +allow if input.subject.id == "alice" +"# + .to_string(), + ) + .unwrap(); + + engine + .set_input_json(r#"{"subject":{"id":"alice"}}"#) + .unwrap(); + let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); + assert_eq!(v.as_bool().copied().ok(), Some(true)); + + // Non-match with a `default` → false (not undefined). + engine + .set_input_json(r#"{"subject":{"id":"eve"}}"#) + .unwrap(); + let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); + assert_eq!(v.as_bool().copied().ok(), Some(false)); + } + + #[test] + fn undefined_when_no_default_and_no_match() { + let mut engine = Engine::new(); + engine + .add_policy( + "authz.rego".to_string(), + r#"package authz +allow if input.subject.id == "alice" +"# + .to_string(), + ) + .unwrap(); + engine + .set_input_json(r#"{"subject":{"id":"eve"}}"#) + .unwrap(); + let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); + // No matching rule and no `default` → undefined, distinct from false. + assert!(matches!(v, regorus::Value::Undefined), "got {v:?}"); + } + + #[test] + fn deny_set_idiom_yields_set() { + let mut engine = Engine::new(); + engine + .add_policy( + "authz.rego".to_string(), + r#"package authz +deny contains msg if { + input.subject.id != "alice" + msg := "subject not allowed" +} +"# + .to_string(), + ) + .unwrap(); + + // Violating input → non-empty set. + engine + .set_input_json(r#"{"subject":{"id":"eve"}}"#) + .unwrap(); + let v = engine.eval_rule("data.authz.deny".to_string()).unwrap(); + match &v { + regorus::Value::Set(s) => assert_eq!(s.len(), 1), + other => panic!("expected Set, got {other:?}"), + } + + // Passing input → empty set. + engine + .set_input_json(r#"{"subject":{"id":"alice"}}"#) + .unwrap(); + let v = engine.eval_rule("data.authz.deny".to_string()).unwrap(); + match &v { + regorus::Value::Set(s) => assert!(s.is_empty()), + other => panic!("expected empty Set, got {other:?}"), + } + } + + #[test] + fn data_document_is_readable_by_policy() { + let mut engine = Engine::new(); + engine + .add_data_json(r#"{"roles":{"alice":["reader"]}}"#) + .unwrap(); + engine + .add_policy( + "authz.rego".to_string(), + r#"package authz +default allow := false +allow if "reader" in data.roles[input.subject.id] +"# + .to_string(), + ) + .unwrap(); + engine + .set_input_json(r#"{"subject":{"id":"alice"}}"#) + .unwrap(); + let v = engine.eval_rule("data.authz.allow".to_string()).unwrap(); + assert_eq!(v.as_bool().copied().ok(), Some(true)); + } + + #[test] + fn parse_error_surfaces_at_add_policy() { + let mut engine = Engine::new(); + let err = engine.add_policy("bad.rego".to_string(), "package x\nallow if {".to_string()); + assert!(err.is_err(), "malformed Rego must fail at add_policy"); + } + + #[test] + fn clone_shares_compiled_policy_and_isolates_input() { + let mut base = Engine::new(); + base.add_policy( + "authz.rego".to_string(), + r#"package authz +default allow := false +allow if input.subject.id == "alice" +"# + .to_string(), + ) + .unwrap(); + + let mut a = base.clone(); + a.set_input_json(r#"{"subject":{"id":"alice"}}"#).unwrap(); + let mut b = base.clone(); + b.set_input_json(r#"{"subject":{"id":"eve"}}"#).unwrap(); + + assert_eq!( + a.eval_rule("data.authz.allow".to_string()) + .unwrap() + .as_bool() + .copied() + .ok(), + Some(true) + ); + assert_eq!( + b.eval_rule("data.authz.allow".to_string()) + .unwrap() + .as_bool() + .copied() + .ok(), + Some(false) + ); + } +} From f9fa56b36c9ba01f2d24a0d50c9502ff2e9428f3 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 00:28:49 -0400 Subject: [PATCH 02/10] feat(pdp-opa): config parsing, decision contract, and builtin wiring Add config parsing that prepares a base regorus engine once from global Rego modules and data documents, failing at load on malformed policy or config. The resolver clones that base per request, sets the request input, and evaluates the configured query. The decision contract maps a boolean, a decision object (allow/deny read from a configurable field, with reason and violations flowing into the deny), or a set/array (deny-set idiom: empty allows, non-empty denies) to an allow/deny decision. An undefined result is a clean deny independent of on_error; genuine eval errors and non-decision values route through on_error (default deny); malformed Rego always denies. Inline modules are compiled once into a bounded, never-evicting cache. Register the opa PDP behind an optional feature in cpex-builtins, on by default alongside cedar and cel. --- builtins/pdps/opa/src/decision.rs | 312 ++++++++++++ builtins/pdps/opa/src/error.rs | 48 ++ builtins/pdps/opa/src/factory.rs | 55 +++ builtins/pdps/opa/src/lib.rs | 56 ++- builtins/pdps/opa/src/resolver.rs | 769 ++++++++++++++++++++++++++++++ crates/cpex-builtins/Cargo.toml | 4 +- crates/cpex-builtins/src/lib.rs | 8 +- 7 files changed, 1248 insertions(+), 4 deletions(-) create mode 100644 builtins/pdps/opa/src/decision.rs create mode 100644 builtins/pdps/opa/src/error.rs create mode 100644 builtins/pdps/opa/src/factory.rs create mode 100644 builtins/pdps/opa/src/resolver.rs diff --git a/builtins/pdps/opa/src/decision.rs b/builtins/pdps/opa/src/decision.rs new file mode 100644 index 00000000..de5eb1c5 --- /dev/null +++ b/builtins/pdps/opa/src/decision.rs @@ -0,0 +1,312 @@ +// Location: ./builtins/pdps/opa/src/decision.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Query-result → decision mapping. +// +// A Rego query resolves to a `regorus::Value`. This module turns that value +// into an APL `PdpDecision` per the decision contract: +// +// - Bool(true) → Allow +// - Bool(false) → Deny (query evaluated to false) +// - Object → read the decision field (default +// `allow`) as a bool; true → Allow, +// false → Deny enriched with the +// object's reason/message, violations, +// and rule id +// - Set / Array (deny-set idiom) → empty → Allow; non-empty → Deny with +// the elements as violations +// - Undefined → clean Deny (idiomatic "not granted"), +// independent of on_error +// - anything else, or an object whose → Degenerate: the caller routes this +// decision field is missing/non-bool through `on_error` +// +// A Deny carries a human-readable `reason`, a `rule_source` (a policy-supplied +// id when present, else `"opa"`), and diagnostics detailing the cause so an +// auditor can see why without re-running the policy. + +use regorus::Value; + +use apl_core::evaluator::Decision; +use apl_core::step::PdpDecision; + +/// The fallback attribution when a policy does not name a rule id. +const DEFAULT_RULE_SOURCE: &str = "opa"; + +/// Outcome of mapping a query result. A `Decision` is terminal (allow/deny); +/// `Degenerate` means the value carries no decision and the caller applies +/// `on_error`. +pub(crate) enum Mapped { + Decision(PdpDecision), + Degenerate(String), +} + +/// Map a successful query result into a decision (or a degenerate marker). +pub(crate) fn map_query_result(value: &Value, decision_field: &str) -> Mapped { + match value { + Value::Bool(true) => Mapped::Decision(allow()), + Value::Bool(false) => Mapped::Decision(deny( + "OPA query evaluated to false".to_string(), + DEFAULT_RULE_SOURCE.to_string(), + Vec::new(), + )), + Value::Object(_) => map_object(value, decision_field), + Value::Set(items) => map_collection(items.iter()), + Value::Array(items) => map_collection(items.iter()), + // Undefined is Rego's idiomatic "no rule granted access" — a clean + // deny, never routed through on_error (so on_error: allow cannot flip + // an ordinary non-match to allow). + Value::Undefined => Mapped::Decision(deny( + "OPA query undefined — request not granted".to_string(), + DEFAULT_RULE_SOURCE.to_string(), + Vec::new(), + )), + other => Mapped::Degenerate(format!( + "OPA query returned a value that carries no decision: {}", + render(other) + )), + } +} + +/// Object result: the decision boolean comes from `decision_field`. +fn map_object(value: &Value, decision_field: &str) -> Mapped { + let obj = match value.as_object() { + Ok(o) => o, + Err(_) => return Mapped::Degenerate("OPA query object was not an object".to_string()), + }; + + let decision = obj + .get(&Value::from(decision_field)) + .and_then(|v| v.as_bool().ok().copied()); + + match decision { + Some(true) => Mapped::Decision(allow()), + Some(false) => { + let reason = get_str(obj, "reason") + .or_else(|| get_str(obj, "message")) + .unwrap_or_else(|| "OPA policy denied the request".to_string()); + let rule_source = get_str(obj, "rule_source") + .or_else(|| get_str(obj, "id")) + .unwrap_or_else(|| DEFAULT_RULE_SOURCE.to_string()); + + let mut diagnostics = Vec::new(); + // Recognized violation lists become individual diagnostics. + for key in ["violations", "errors"] { + if let Some(list) = obj.get(&Value::from(key)).and_then(|v| v.as_array().ok()) { + diagnostics.extend(list.iter().map(render)); + } + } + // Serialize the whole object so nothing the author returned is lost + // to the audit trail, even fields we did not specifically read. + diagnostics.push(format!("opa: {}", render(value))); + + Mapped::Decision(deny(reason, rule_source, diagnostics)) + }, + None => Mapped::Degenerate(format!( + "OPA decision object has no boolean `{decision_field}` field: {}", + render(value) + )), + } +} + +/// Set/array (deny-set / violation-set idiom): empty → allow, non-empty → deny +/// with the elements as violations. +fn map_collection<'a>(items: impl ExactSizeIterator) -> Mapped { + let violations: Vec = items.map(render).collect(); + if violations.is_empty() { + Mapped::Decision(allow()) + } else { + let reason = format!("OPA policy produced {} violation(s)", violations.len()); + Mapped::Decision(deny(reason, DEFAULT_RULE_SOURCE.to_string(), violations)) + } +} + +fn allow() -> PdpDecision { + PdpDecision { + decision: Decision::Allow, + diagnostics: Vec::new(), + } +} + +fn deny(reason: String, rule_source: String, diagnostics: Vec) -> PdpDecision { + PdpDecision { + decision: Decision::Deny { + reason: Some(reason), + rule_source, + }, + diagnostics, + } +} + +/// Read an object field as a string, if present and string-typed. +fn get_str(obj: ®orus::value::Object, key: &str) -> Option { + obj.get(&Value::from(key)) + .and_then(|v| v.as_string().ok()) + .map(|s| s.to_string()) +} + +/// Render a value for a diagnostic line: a bare string as-is, everything else +/// as compact JSON (falling back to a placeholder if it cannot be rendered). +fn render(value: &Value) -> String { + if let Ok(s) = value.as_string() { + return s.to_string(); + } + value + .to_json_str() + .unwrap_or_else(|_| "".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn val(json: &str) -> Value { + Value::from_json_str(json).unwrap() + } + + fn decision_of(m: Mapped) -> Decision { + match m { + Mapped::Decision(d) => d.decision, + Mapped::Degenerate(c) => panic!("expected a decision, got degenerate: {c}"), + } + } + + #[test] + fn bool_true_allows_false_denies() { + assert_eq!( + decision_of(map_query_result(&Value::Bool(true), "allow")), + Decision::Allow + ); + assert!(matches!( + decision_of(map_query_result(&Value::Bool(false), "allow")), + Decision::Deny { .. } + )); + } + + #[test] + fn undefined_is_clean_deny() { + assert!(matches!( + decision_of(map_query_result(&Value::Undefined, "allow")), + Decision::Deny { rule_source, .. } if rule_source == "opa" + )); + } + + #[test] + fn object_allow_true_allows() { + assert_eq!( + decision_of(map_query_result(&val(r#"{"allow": true}"#), "allow")), + Decision::Allow + ); + } + + #[test] + fn object_deny_carries_reason_and_violations() { + let m = map_query_result( + &val( + r#"{"allow": false, "reason": "subject not in allowlist", "violations": ["no reader role"]}"#, + ), + "allow", + ); + match m { + Mapped::Decision(d) => { + match d.decision { + Decision::Deny { + reason, + rule_source, + } => { + assert_eq!(reason.as_deref(), Some("subject not in allowlist")); + assert_eq!(rule_source, "opa"); + }, + other => panic!("expected Deny, got {other:?}"), + } + assert!( + d.diagnostics.iter().any(|x| x == "no reader role"), + "violations must appear in diagnostics; got {:?}", + d.diagnostics + ); + }, + Mapped::Degenerate(c) => panic!("degenerate: {c}"), + } + } + + #[test] + fn object_message_field_used_when_no_reason() { + let m = map_query_result(&val(r#"{"allow": false, "message": "blocked"}"#), "allow"); + match decision_of(m) { + Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("blocked")), + other => panic!("expected Deny, got {other:?}"), + } + } + + #[test] + fn object_policy_id_becomes_rule_source() { + let m = map_query_result( + &val(r#"{"allow": false, "rule_source": "owner-override"}"#), + "allow", + ); + match decision_of(m) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "owner-override"), + other => panic!("expected Deny, got {other:?}"), + } + } + + #[test] + fn custom_decision_field_is_honored() { + assert_eq!( + decision_of(map_query_result(&val(r#"{"permit": true}"#), "permit")), + Decision::Allow + ); + } + + #[test] + fn object_without_decision_field_is_degenerate() { + assert!(matches!( + map_query_result(&val(r#"{"note": "hi"}"#), "allow"), + Mapped::Degenerate(_) + )); + } + + #[test] + fn object_non_bool_decision_field_is_degenerate() { + assert!(matches!( + map_query_result(&val(r#"{"allow": "yes"}"#), "allow"), + Mapped::Degenerate(_) + )); + } + + #[test] + fn empty_array_allows_nonempty_denies() { + assert_eq!( + decision_of(map_query_result(&val("[]"), "allow")), + Decision::Allow + ); + match decision_of(map_query_result(&val(r#"["blocked: reason"]"#), "allow")) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "opa"), + other => panic!("expected Deny, got {other:?}"), + } + } + + #[test] + fn nonempty_set_denies_with_violations() { + use std::collections::BTreeSet; + let mut set = BTreeSet::new(); + set.insert(Value::from("no reader role")); + let m = map_query_result(&Value::from(set), "allow"); + match m { + Mapped::Decision(d) => { + assert!(matches!(d.decision, Decision::Deny { .. })); + assert!(d.diagnostics.iter().any(|x| x == "no reader role")); + }, + Mapped::Degenerate(c) => panic!("degenerate: {c}"), + } + } + + #[test] + fn string_result_is_degenerate() { + assert!(matches!( + map_query_result(&Value::from("hello"), "allow"), + Mapped::Degenerate(_) + )); + } +} diff --git a/builtins/pdps/opa/src/error.rs b/builtins/pdps/opa/src/error.rs new file mode 100644 index 00000000..74167a09 --- /dev/null +++ b/builtins/pdps/opa/src/error.rs @@ -0,0 +1,48 @@ +// Location: ./builtins/pdps/opa/src/error.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// Build-time errors for the OPA PDP. These surface at config-load time (when +// the apl-cpex visitor calls the factory), so an operator sees bad policy or +// malformed config at deploy rather than on the first request. Mirrors the +// shape of `cpex-pdp-cedar-direct`'s `BuildError`. + +use thiserror::Error; + +/// Errors produced while building an `OpaResolver` from its config block. +#[derive(Debug, Error)] +pub enum BuildError { + /// The config block is not shaped as expected (not a mapping, unknown key, + /// wrong value type for a known key). + #[error("invalid OPA PDP config: {0}")] + ConfigShape(String), + + /// A `module_files` entry could not be read from disk. + #[error("failed to read OPA module file '{path}': {source}")] + ModuleFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// A Rego module (inline or from a file) failed to parse/compile. This is + /// an author bug surfaced at load time so it never reaches evaluation. The + /// regorus cause is stringified (its error is a type-erased `anyhow::Error`, + /// so there is no variant to carry). + #[error("failed to load Rego module '{name}': {cause}")] + ModuleParse { name: String, cause: String }, + + /// A `data_files` entry could not be read from disk. + #[error("failed to read OPA data file '{path}': {source}")] + DataFile { + path: String, + #[source] + source: std::io::Error, + }, + + /// A `data` document (inline or from a file) could not be parsed or merged + /// into the engine's `data` root. + #[error("failed to load OPA data '{name}': {cause}")] + DataParse { name: String, cause: String }, +} diff --git a/builtins/pdps/opa/src/factory.rs b/builtins/pdps/opa/src/factory.rs new file mode 100644 index 00000000..2f0c7a32 --- /dev/null +++ b/builtins/pdps/opa/src/factory.rs @@ -0,0 +1,55 @@ +// Location: ./builtins/pdps/opa/src/factory.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// `OpaPdpFactory` — the `PdpFactory` the apl-cpex visitor uses to instantiate +// an `OpaResolver` from a unified-config block: +// +// ```yaml +// global: +// apl: +// pdp: +// - kind: opa +// on_error: deny # optional; deny | allow, default deny +// modules: # global Rego modules (inline and/or files) +// - | +// package authz +// default allow := false +// allow if input.subject.id == "alice" +// ``` +// +// The per-route query (and any inline module) lives in each route's +// `opa: { query: "data.authz.allow" }` step, not in this block. Hosts register +// an instance in `AplOptions.pdp_factories`; the visitor matches it by `kind`. + +use std::sync::Arc; + +use apl_core::step::{PdpFactory, PdpResolver}; + +use crate::resolver::OpaResolver; + +/// Factory for `OpaResolver`. Reports `kind() = "opa"`; builds resolvers from +/// the unified-config block via [`OpaResolver::from_config`]. +#[derive(Default)] +pub struct OpaPdpFactory; + +impl OpaPdpFactory { + pub fn new() -> Self { + Self + } +} + +impl PdpFactory for OpaPdpFactory { + fn kind(&self) -> &str { + "opa" + } + + fn build( + &self, + config: &serde_yaml::Value, + ) -> Result, Box> { + let resolver = OpaResolver::from_config(config)?; + Ok(Arc::new(resolver)) + } +} diff --git a/builtins/pdps/opa/src/lib.rs b/builtins/pdps/opa/src/lib.rs index 1fc34a9a..d6473eec 100644 --- a/builtins/pdps/opa/src/lib.rs +++ b/builtins/pdps/opa/src/lib.rs @@ -3,11 +3,63 @@ // SPDX-License-Identifier: Apache-2.0 // Authors: Fred Araujo // -// cpex-pdp-opa — `PdpResolver` over the pure-Rust `regorus` Rego interpreter. +// cpex-pdp-opa — `PdpResolver` over Microsoft's pure-Rust `regorus` Rego +// interpreter. // -// (Full crate docs land with the resolver in U3/U4.) +// # Where this lives in the stack +// +// APL evaluator (apl-core) +// │ `opa: { query: "data.authz.allow" }` step +// ▼ +// PdpRouter (apl-cpex) — dispatches by dialect (PdpDialect::Opa) +// │ resolver.evaluate(call, bag) +// ▼ +// OpaResolver — THIS CRATE +// │ bag → Rego input, clone base engine, eval query +// ▼ +// regorus::Engine — embedded Rego evaluation, no sidecar +// +// # Policy source (hybrid) +// +// Rego modules and `data` are declared in the `global.pdp` block and parsed +// once at factory-build time; a route step may also carry an inline `module`. +// See `resolver::OpaResolver::from_config` for the config shape and +// `factory::OpaPdpFactory` for how the visitor builds it. +// +// # The attribute vocabulary (bag → input) +// +// APL's flat, dotted `AttributeBag` (`subject.id`, `delegation.depth`, +// `session.labels`) is rebuilt into a nested Rego `input` document so authors +// write `input.subject.id`. See `input::bag_to_input` — the mapping mirrors +// the CEL resolver's so the vocabulary is identical across backends. +// +// # Decision contract +// +// The step's `query` must resolve to a boolean, a decision object (whose +// allow/deny bit is read from `decision_field`, default `allow`), or a +// set/array (the deny-set idiom: empty → allow, non-empty → deny). An +// undefined result is a clean deny — Rego's idiomatic "not granted" — never +// routed through `on_error`. A value carrying no decision, or a genuine eval +// error, is governed by `on_error` (default `deny`). A Rego parse/compile +// error always denies. See `decision::map_query_result`. +// +// # Evaluation model +// +// A base `regorus::Engine` holds the compiled global policy + data. Because +// the `arc` feature makes that state `Arc`-shared, the resolver clones the base +// per request (cheap), sets the request `input`, and evaluates — no lock on the +// hot path. Inline modules get a bounded prepared-engine cache. See +// `resolver` for the details. +pub mod decision; +pub mod error; +pub mod factory; pub mod input; +pub mod resolver; + +pub use error::BuildError; +pub use factory::OpaPdpFactory; +pub use resolver::{OnError, OpaResolver}; #[cfg(test)] mod regorus_api_smoke { diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs new file mode 100644 index 00000000..3e5eb9ca --- /dev/null +++ b/builtins/pdps/opa/src/resolver.rs @@ -0,0 +1,769 @@ +// Location: ./builtins/pdps/opa/src/resolver.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// `OpaResolver` — the `PdpResolver` implementation over regorus. +// +// # Build once, evaluate many +// +// `from_config` prepares a base `regorus::Engine` at factory-build time: it +// parses every global Rego module and loads every `data` document a single +// time. Because regorus (with the `arc` feature) reference-counts its compiled +// policy and data behind atomic `Arc`, cloning the base engine is cheap and +// shares that compiled state. Every `evaluate` call therefore clones the base, +// sets the request `input`, and evaluates — no lock on the hot path, no +// re-parse. (All regorus `set_input`/`eval_*` methods take `&mut self`, so a +// per-request clone is what makes concurrent evaluation possible at all.) +// +// Inline `opa: { module: "..." }` steps get their own bounded cache of +// prepared engines (base + that module), so a distinct inline module is parsed +// at most once. The cache follows the workspace "cap + reject + log, never +// evict" convention. +// +// # Decision contract +// +// The configured query must resolve to a boolean, a decision object, or a +// set/array (see `crate::decision`). Fail-closed by default: an evaluation +// error routes through `on_error` (default `Deny`); a Rego parse error always +// denies. + +use std::collections::HashMap; +use std::sync::RwLock; + +use async_trait::async_trait; +use regorus::Engine; + +use apl_core::attributes::AttributeBag; +use apl_core::evaluator::Decision; +use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver}; + +use crate::decision::{map_query_result, Mapped}; +use crate::error::BuildError; +use crate::input::bag_to_input; + +/// What to do when a query errors at runtime or yields a value that carries no +/// decision (a non-bool/object/set result, or a missing decision field). A +/// `false`/deny result and an undefined result are NOT governed by this — they +/// are legitimate denials, always honored. Parse/compile errors are never +/// governed by this either: they always deny (an author bug must never flip to +/// allow). Mirrors `cpex-pdp-cel`'s `OnError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OnError { + /// Fail-closed: a degenerate runtime outcome denies. The APL default. + #[default] + Deny, + /// Fail-open: a degenerate runtime outcome allows through. For advisory + /// checks layered behind a hard PDP; the resolver logs at `error!` on this + /// path so it is never silent. + Allow, +} + +/// Default upper bound on the inline-module cache. Inline modules are +/// author-supplied in route YAML, so the cache fills with the policy's static +/// set of distinct inline modules. 1024 is generous for any realistic policy +/// and small enough that a templating bug trips the cap before it balloons +/// memory. Mirrors `cpex-pdp-cel`'s cache cap. +pub const DEFAULT_MAX_CACHE_ENTRIES: usize = 1024; + +/// Virtual filename regorus uses for the query's inline module. Distinct from +/// the `global-.rego` names global modules load under, so an inline module +/// adds to the engine rather than replacing a global one. +pub(crate) const INLINE_MODULE_NAME: &str = "__inline__.rego"; + +#[derive(Debug)] +pub struct OpaResolver { + dialect: PdpDialect, + on_error: OnError, + /// The object field (or, for a set/array result, ignored) that carries the + /// allow/deny boolean when the query resolves to an object. Default + /// `"allow"`. + decision_field: String, + /// The base engine, prepared once with all global modules + data. Cloned + /// per request (cheap — compiled policy/data is `Arc`-shared). + base_engine: Engine, + /// Cache of prepared engines for inline modules, keyed by module source. + /// `RwLock` so the steady-state read path is uncontended once a route's + /// inline module has been prepared. + inline_cache: RwLock>, + /// Upper bound on `inline_cache`. New entries past this are rejected (never + /// evicted), per the workspace cache convention. + max_cache_entries: usize, +} + +impl OpaResolver { + /// Build a resolver from a unified-config block. Shape: + /// + /// ```yaml + /// kind: opa # matched by the factory, not read here + /// on_error: deny # optional; deny | allow, default deny + /// decision_field: allow # optional; object field holding the bool, default "allow" + /// modules: # optional; inline Rego module texts + /// - | + /// package authz + /// default allow := false + /// allow if input.subject.id == "alice" + /// module_files: # optional; paths to Rego module files + /// - policies/authz.rego + /// data: # optional; inline data merged into the `data` root + /// roles: + /// alice: [reader] + /// data_files: # optional; paths to JSON/YAML data files + /// - data/roles.json + /// ``` + /// + /// Global modules and data are parsed/loaded here, once. A Rego parse error + /// or a data merge conflict surfaces as a `BuildError` at load time. + pub fn from_config(value: &serde_yaml::Value) -> Result { + let map = value + .as_mapping() + .ok_or_else(|| BuildError::ConfigShape("OPA PDP config must be a mapping".into()))?; + + // Reject unknown keys so a typo fails loud at load rather than being + // silently dropped. `kind` is consumed by the factory but present here. + const KNOWN_KEYS: &[&str] = &[ + "kind", + "on_error", + "decision_field", + "modules", + "module_files", + "data", + "data_files", + ]; + for (key, _) in map { + let Some(name) = key.as_str() else { + return Err(BuildError::ConfigShape( + "OPA PDP config keys must be strings".into(), + )); + }; + if !KNOWN_KEYS.contains(&name) { + return Err(BuildError::ConfigShape(format!( + "unknown OPA PDP config key `{name}`; expected one of {KNOWN_KEYS:?}" + ))); + } + } + + let on_error = match read_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}`" + ))); + }, + }; + + let decision_field = read_string(map, "decision_field").unwrap_or_else(|| "allow".into()); + + let mut engine = Engine::new(); + + // 1. Global modules — inline texts first, then files. Each gets a + // unique virtual name so same-package modules merge (Rego + // semantics) rather than one overwriting another by filename. + for (module_index, text) in read_string_seq(map, "modules")?.into_iter().enumerate() { + let name = format!("global-{module_index}.rego"); + engine + .add_policy(name.clone(), text) + .map_err(|e| BuildError::ModuleParse { + name, + cause: e.to_string(), + })?; + } + for path in read_string_seq(map, "module_files")? { + let text = std::fs::read_to_string(&path).map_err(|source| BuildError::ModuleFile { + path: path.clone(), + source, + })?; + engine + .add_policy(path.clone(), text) + .map_err(|e| BuildError::ModuleParse { + name: path, + cause: e.to_string(), + })?; + } + + // 2. Data documents — inline mapping first, then files. Both are + // normalized to JSON (serde_yaml parses JSON too, so a `.json` or + // `.yaml` data file both work) and merged into the `data` root. + if let Some(data) = map.get(serde_yaml::Value::String("data".into())) { + if !data.is_null() { + let json = serde_json::to_string(data).map_err(|e| BuildError::DataParse { + name: "data".into(), + cause: e.to_string(), + })?; + engine + .add_data_json(&json) + .map_err(|e| BuildError::DataParse { + name: "data".into(), + cause: e.to_string(), + })?; + } + } + for path in read_string_seq(map, "data_files")? { + let text = std::fs::read_to_string(&path).map_err(|source| BuildError::DataFile { + path: path.clone(), + source, + })?; + let parsed: serde_yaml::Value = + serde_yaml::from_str(&text).map_err(|e| BuildError::DataParse { + name: path.clone(), + cause: e.to_string(), + })?; + let json = serde_json::to_string(&parsed).map_err(|e| BuildError::DataParse { + name: path.clone(), + cause: e.to_string(), + })?; + engine + .add_data_json(&json) + .map_err(|e| BuildError::DataParse { + name: path, + cause: e.to_string(), + })?; + } + + Ok(Self { + dialect: PdpDialect::Opa, + on_error, + decision_field, + base_engine: engine, + inline_cache: RwLock::new(HashMap::new()), + max_cache_entries: DEFAULT_MAX_CACHE_ENTRIES, + }) + } + + /// Override the resolver's dialect. Lets an operator register an OPA engine + /// under a custom name so two OPA resolvers can coexist on one router. + pub fn with_dialect(mut self, dialect: PdpDialect) -> Self { + self.dialect = dialect; + self + } + + /// Override the inline-module cache cap (default + /// [`DEFAULT_MAX_CACHE_ENTRIES`]). + pub fn with_max_cache_entries(mut self, max_cache_entries: usize) -> Self { + self.max_cache_entries = max_cache_entries; + self + } + + /// Get an engine ready to evaluate this step: the base engine when the step + /// carries no inline module, or a cached base+module engine otherwise. The + /// returned engine is a fresh clone the caller mutates (set input, eval) + /// without touching the shared base or cache. Cloning is cheap — regorus + /// (`arc`) `Arc`-shares the compiled policy and data. + fn engine_for(&self, module: Option<&str>) -> Result { + let Some(src) = module else { + return Ok(self.base_engine.clone()); + }; + + // Fast path: this inline module was already prepared. + if let Some(engine) = self + .inline_cache + .read() + .unwrap_or_else(|p| p.into_inner()) + .get(src) + { + return Ok(engine.clone()); + } + + // Prepare base + inline module. A parse failure here is a compile error + // (always denies), not a runtime condition. + let mut engine = self.base_engine.clone(); + engine + .add_policy(INLINE_MODULE_NAME.to_string(), src.to_string()) + .map_err(|e| EngineError::Compile(e.to_string()))?; + + // Insert under the cap — reject past it, never evict (workspace cache + // convention). + let mut cache = self.inline_cache.write().unwrap_or_else(|p| p.into_inner()); + if cache.len() >= self.max_cache_entries && !cache.contains_key(src) { + tracing::warn!( + cap = self.max_cache_entries, + "OPA inline-module cache full; rejecting new module. Existing entries are not \ + evicted. Increase `with_max_cache_entries` if the policy legitimately exceeds \ + the default bound." + ); + return Err(EngineError::CacheFull { + cap: self.max_cache_entries, + }); + } + cache.insert(src.to_string(), engine.clone()); + Ok(engine) + } + + /// Apply `on_error` to a degenerate RUNTIME outcome (eval error, a value + /// carrying no decision, or a cache-full rejection). Allow logs at `error!` + /// so a misused fail-open flag is never silent in production. + fn on_error_decision(&self, cause: String) -> PdpDecision { + match self.on_error { + OnError::Allow => { + tracing::error!( + cause = %cause, + "OPA 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: "opa".to_string(), + }, + diagnostics: vec![cause], + }, + } + } + + /// A Rego compile error always denies, regardless of `on_error` — malformed + /// policy is an author bug and must never flip to allow. + fn compile_error_decision(&self, cause: String) -> PdpDecision { + tracing::error!( + cause = %cause, + "OPA compile error — denying the request regardless of on_error mode." + ); + PdpDecision { + decision: Decision::Deny { + reason: Some(cause.clone()), + rule_source: "opa".to_string(), + }, + diagnostics: vec![cause], + } + } +} + +/// Internal — failure shapes from preparing a per-step engine. +enum EngineError { + /// A Rego parse/compile error in an inline module. Always denies. + Compile(String), + /// The inline-module cache hit its cap. A runtime resource limit routed + /// through `on_error`, matching the CEL resolver's cache-full handling. + CacheFull { cap: usize }, +} + +#[async_trait] +impl PdpResolver for OpaResolver { + fn dialect(&self) -> PdpDialect { + self.dialect.clone() + } + + async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result { + // 1. Required `query` and optional inline `module` from the step args. + // A missing `query` is an author/config bug — hard error. + let args = call.args.as_mapping(); + let query = args + .and_then(|m| m.get(serde_yaml::Value::String("query".into()))) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + PdpError::Dispatch("opa: step requires a string `query` argument".to_string()) + })?; + let module = args + .and_then(|m| m.get(serde_yaml::Value::String("module".into()))) + .and_then(|v| v.as_str()); + + // 2. Prepare the engine. Compile errors always deny; a cache-full + // rejection routes through on_error. + let mut engine = match self.engine_for(module) { + Ok(engine) => engine, + Err(EngineError::Compile(cause)) => { + return Ok(self.compile_error_decision(format!("OPA inline module: {cause}"))); + }, + Err(EngineError::CacheFull { cap }) => { + return Ok(self.on_error_decision(format!( + "OPA inline-module cache full (cap={cap}); refusing a new module" + ))); + }, + }; + + // 3. Map the bag into the Rego `input` document. + let input = bag_to_input(bag); + if let Err(e) = engine.set_input_json(&input.to_string()) { + return Ok(self.on_error_decision(format!("OPA failed to set input: {e}"))); + } + + // 4. Evaluate the query and map the result to a decision. + match engine.eval_rule(query.to_string()) { + Ok(value) => match map_query_result(&value, &self.decision_field) { + Mapped::Decision(decision) => Ok(decision), + Mapped::Degenerate(cause) => Ok(self.on_error_decision(cause)), + }, + Err(e) => Ok(self.on_error_decision(format!("OPA eval error: {e}"))), + } + } +} + +/// Read an optional string field from a YAML mapping. +fn read_string(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(serde_yaml::Value::String(key.to_string()))? + .as_str() + .map(|s| s.to_string()) +} + +/// Read an optional sequence-of-strings field. A missing key yields an empty +/// vec; a present-but-non-sequence value, or a non-string element, is a config +/// error. +fn read_string_seq(map: &serde_yaml::Mapping, key: &str) -> Result, BuildError> { + let Some(value) = map.get(serde_yaml::Value::String(key.to_string())) else { + return Ok(Vec::new()); + }; + if value.is_null() { + return Ok(Vec::new()); + } + let seq = value + .as_sequence() + .ok_or_else(|| BuildError::ConfigShape(format!("`{key}` must be a sequence of strings")))?; + seq.iter() + .map(|item| { + item.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| BuildError::ConfigShape(format!("`{key}` entries must be strings"))) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(yaml: &str) -> Result { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + OpaResolver::from_config(&value) + } + + #[test] + fn builds_from_inline_module_and_data() { + let r = cfg(r#" +kind: opa +on_error: deny +modules: + - | + package authz + default allow := false + allow if input.subject.id == "alice" +data: + roles: + alice: [reader] +"#) + .expect("should build"); + assert_eq!(r.on_error, OnError::Deny); + assert_eq!(r.decision_field, "allow"); + } + + #[test] + fn on_error_allow_parses() { + let r = cfg("kind: opa\non_error: allow\n").unwrap(); + assert_eq!(r.on_error, OnError::Allow); + } + + #[test] + fn on_error_bad_value_rejected() { + let err = cfg("kind: opa\non_error: maybe\n").unwrap_err(); + assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("on_error"))); + } + + #[test] + fn decision_field_override_read() { + let r = cfg("kind: opa\ndecision_field: permit\nmodules:\n - \"package p\"\n").unwrap(); + assert_eq!(r.decision_field, "permit"); + } + + #[test] + fn unknown_key_rejected_naming_the_key() { + let err = cfg("kind: opa\non_errr: allow\n").unwrap_err(); + match err { + BuildError::ConfigShape(m) => assert!(m.contains("on_errr"), "got {m}"), + other => panic!("expected ConfigShape, got {other:?}"), + } + } + + #[test] + fn rego_parse_error_surfaces_at_build() { + let err = cfg("kind: opa\nmodules:\n - \"package x\\nallow if {\"\n").unwrap_err(); + assert!(matches!(err, BuildError::ModuleParse { .. }), "got {err:?}"); + } + + #[test] + fn missing_module_file_names_the_path() { + let err = cfg("kind: opa\nmodule_files:\n - /no/such/authz.rego\n").unwrap_err(); + match err { + BuildError::ModuleFile { path, .. } => assert_eq!(path, "/no/such/authz.rego"), + other => panic!("expected ModuleFile, got {other:?}"), + } + } + + #[test] + fn config_must_be_a_mapping() { + let value: serde_yaml::Value = serde_yaml::from_str("- just\n- a\n- list\n").unwrap(); + assert!(matches!( + OpaResolver::from_config(&value), + Err(BuildError::ConfigShape(_)) + )); + } + + #[test] + fn modules_must_be_a_sequence() { + let err = cfg("kind: opa\nmodules: not-a-list\n").unwrap_err(); + assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("modules"))); + } +} + +#[cfg(test)] +mod eval_tests { + use super::*; + use std::sync::Arc; + + /// Build a resolver from a set of inline global modules. + fn resolver(modules: &[&str], on_error: OnError) -> OpaResolver { + let mut map = serde_yaml::Mapping::new(); + map.insert(sv("kind"), sv("opa")); + if on_error == OnError::Allow { + map.insert(sv("on_error"), sv("allow")); + } + let mods = modules.iter().map(|m| sv(m)).collect(); + map.insert(sv("modules"), serde_yaml::Value::Sequence(mods)); + OpaResolver::from_config(&serde_yaml::Value::Mapping(map)).unwrap() + } + + fn sv(s: &str) -> serde_yaml::Value { + serde_yaml::Value::String(s.to_string()) + } + + /// Build an `opa:` step call with a query and optional inline module. + fn call(query: &str, module: Option<&str>) -> PdpCall { + let mut m = serde_yaml::Mapping::new(); + m.insert(sv("query"), sv(query)); + if let Some(src) = module { + m.insert(sv("module"), sv(src)); + } + PdpCall { + dialect: PdpDialect::Opa, + args: serde_yaml::Value::Mapping(m), + } + } + + fn bag(subject_id: &str) -> AttributeBag { + let mut b = AttributeBag::new(); + b.set("subject.id", subject_id); + b + } + + const ALLOW_WITH_DEFAULT: &str = r#"package authz +default allow := false +allow if input.subject.id == "alice" +"#; + + const ALLOW_NO_DEFAULT: &str = r#"package authz +allow if input.subject.id == "alice" +"#; + + const DENY_SET: &str = r#"package authz +deny contains msg if { + input.subject.id != "alice" + msg := "subject not allowed" +} +"#; + + const DECISION_OBJECT: &str = r#"package authz +result := {"allow": input.subject.id == "alice"} +"#; + + const STRING_RESULT: &str = r#"package authz +msg := "not a decision" +"#; + + #[tokio::test] + async fn allow_when_policy_grants() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); + let out = r + .evaluate(&call("data.authz.allow", None), &bag("alice")) + .await + .unwrap(); + assert_eq!(out.decision, Decision::Allow); + } + + #[tokio::test] + async fn deny_when_policy_returns_false() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); + let out = r + .evaluate(&call("data.authz.allow", None), &bag("eve")) + .await + .unwrap(); + assert!(matches!(out.decision, Decision::Deny { .. })); + } + + /// Undefined (non-match with no `default`) is a clean deny even under + /// on_error: allow — it must never fail open. + #[tokio::test] + async fn undefined_denies_even_with_on_error_allow() { + let r = resolver(&[ALLOW_NO_DEFAULT], OnError::Allow); + let out = r + .evaluate(&call("data.authz.allow", None), &bag("eve")) + .await + .unwrap(); + match out.decision { + Decision::Deny { reason, .. } => { + assert!(reason.unwrap_or_default().contains("undefined")); + }, + other => panic!("undefined must deny, got {other:?}"), + } + } + + #[tokio::test] + async fn deny_set_empty_allows_nonempty_denies() { + let r = resolver(&[DENY_SET], OnError::Deny); + // Passing subject → empty deny set → allow. + let allow = r + .evaluate(&call("data.authz.deny", None), &bag("alice")) + .await + .unwrap(); + assert_eq!(allow.decision, Decision::Allow); + // Violating subject → non-empty deny set → deny with the message. + let deny = r + .evaluate(&call("data.authz.deny", None), &bag("eve")) + .await + .unwrap(); + assert!(matches!(deny.decision, Decision::Deny { .. })); + assert!(deny.diagnostics.iter().any(|d| d == "subject not allowed")); + } + + #[tokio::test] + async fn decision_object_allow_and_deny() { + let r = resolver(&[DECISION_OBJECT], OnError::Deny); + let allow = r + .evaluate(&call("data.authz.result", None), &bag("alice")) + .await + .unwrap(); + assert_eq!(allow.decision, Decision::Allow); + let deny = r + .evaluate(&call("data.authz.result", None), &bag("eve")) + .await + .unwrap(); + assert!(matches!(deny.decision, Decision::Deny { .. })); + } + + /// A value that carries no decision (a bare string) is degenerate → routes + /// through on_error: deny by default, allow when configured. + #[tokio::test] + async fn non_decision_value_routes_through_on_error() { + let deny_r = resolver(&[STRING_RESULT], OnError::Deny); + let deny = deny_r + .evaluate(&call("data.authz.msg", None), &bag("alice")) + .await + .unwrap(); + assert!(matches!(deny.decision, Decision::Deny { .. })); + + let allow_r = resolver(&[STRING_RESULT], OnError::Allow); + let allow = allow_r + .evaluate(&call("data.authz.msg", None), &bag("alice")) + .await + .unwrap(); + assert_eq!(allow.decision, Decision::Allow); + } + + /// An inline module with a Rego syntax error always denies, even under + /// on_error: allow — malformed policy never flips to allow. + #[tokio::test] + async fn inline_compile_error_always_denies() { + let r = resolver(&[], OnError::Allow); + let out = r + .evaluate( + &call("data.x.allow", Some("package x\nallow if {")), + &bag("alice"), + ) + .await + .unwrap(); + match out.decision { + Decision::Deny { reason, .. } => { + assert!(reason.unwrap_or_default().contains("inline module")); + }, + other => panic!("compile error must deny, got {other:?}"), + } + } + + /// A same-package inline module merges with a global module (Rego + /// semantics) — no error on package reuse — and can add a rule. + #[tokio::test] + async fn inline_module_merges_with_global_package() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); + // Add a rule in the same `authz` package via an inline module and query + // it. The merge must succeed (not error), and the new rule evaluates. + let inline = "package authz\nextra if input.subject.id == \"alice\"\n"; + let out = r + .evaluate(&call("data.authz.extra", Some(inline)), &bag("alice")) + .await + .unwrap(); + assert_eq!(out.decision, Decision::Allow); + } + + #[tokio::test] + async fn missing_query_is_dispatch_error() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); + let call = PdpCall { + dialect: PdpDialect::Opa, + args: serde_yaml::Value::Null, + }; + let err = r.evaluate(&call, &bag("alice")).await.unwrap_err(); + assert!(matches!(err, PdpError::Dispatch(_))); + } + + /// At the inline-module cache cap, a new distinct inline module is rejected + /// and routed through on_error; an already-cached module still evaluates. + #[tokio::test] + async fn inline_cache_cap_rejects_new_modules() { + let r = resolver(&[], OnError::Deny).with_max_cache_entries(1); + let m1 = "package a\nallow if input.subject.id == \"alice\"\n"; + let m2 = "package b\nallow if input.subject.id == \"alice\"\n"; + + // First inline module fills the cache and evaluates. + let first = r + .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) + .await + .unwrap(); + assert_eq!(first.decision, Decision::Allow); + + // Second distinct module → cap rejection → on_error deny. + let second = r + .evaluate(&call("data.b.allow", Some(m2)), &bag("alice")) + .await + .unwrap(); + assert!(matches!(second.decision, Decision::Deny { .. })); + assert!(second.diagnostics.iter().any(|d| d.contains("cache full"))); + + // Cached module still works. + let again = r + .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) + .await + .unwrap(); + assert_eq!(again.decision, Decision::Allow); + } + + /// Many threads sharing one `Arc` evaluate concurrently and + /// each gets the correct per-request decision (exercises clone-per-request + /// under the `arc` feature). + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_evaluation_is_correct() { + let r = Arc::new(resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny)); + let tasks: Vec<_> = (0..64) + .map(|i| { + let r = Arc::clone(&r); + tokio::spawn(async move { + let id = if i % 2 == 0 { "alice" } else { "eve" }; + let out = r + .evaluate(&call("data.authz.allow", None), &bag(id)) + .await + .unwrap(); + (id, out.decision) + }) + }) + .collect(); + for t in tasks { + let (id, decision) = t.await.unwrap(); + if id == "alice" { + assert_eq!(decision, Decision::Allow); + } else { + assert!(matches!(decision, Decision::Deny { .. })); + } + } + } +} diff --git a/crates/cpex-builtins/Cargo.toml b/crates/cpex-builtins/Cargo.toml index bd0723c9..13383363 100644 --- a/crates/cpex-builtins/Cargo.toml +++ b/crates/cpex-builtins/Cargo.toml @@ -29,7 +29,7 @@ rust-version.workspace = true [features] # The common in-process set: the four hook plugins plus both PDPs. Heavier # / external-backend extensions (valkey) stay opt-in via `full`. -default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "elicitation-ciba", "cedar-direct", "cel"] +default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth", "elicitation-ciba", "cedar-direct", "cel", "opa"] # Each feature pulls exactly one builtin crate. pii-scanner = ["dep:cpex-plugin-pii-scanner"] @@ -39,6 +39,7 @@ delegator-oauth = ["dep:cpex-plugin-delegator-oauth"] elicitation-ciba = ["dep:cpex-plugin-elicitation-ciba"] cedar-direct = ["dep:cpex-pdp-cedar-direct"] cel = ["dep:cpex-pdp-cel"] +opa = ["dep:cpex-pdp-opa"] valkey = ["dep:cpex-session-valkey"] # Everything wired here, including the Valkey session store (redis client + @@ -59,6 +60,7 @@ cpex-plugin-delegator-oauth = { workspace = true, optional = true } cpex-plugin-elicitation-ciba = { workspace = true, optional = true } cpex-pdp-cedar-direct = { workspace = true, optional = true } cpex-pdp-cel = { workspace = true, optional = true } +cpex-pdp-opa = { workspace = true, optional = true } cpex-session-valkey = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/cpex-builtins/src/lib.rs b/crates/cpex-builtins/src/lib.rs index 296a3ced..edf999f6 100644 --- a/crates/cpex-builtins/src/lib.rs +++ b/crates/cpex-builtins/src/lib.rs @@ -38,6 +38,8 @@ use cpex_core::manager::PluginManager; pub use cpex_pdp_cedar_direct::CedarDirectPdpFactory; #[cfg(feature = "cel")] pub use cpex_pdp_cel::CelPdpFactory; +#[cfg(feature = "opa")] +pub use cpex_pdp_opa::OpaPdpFactory; #[cfg(feature = "audit-logger")] pub use cpex_plugin_audit_logger::{AuditLoggerFactory, KIND as AUDIT_KIND}; #[cfg(feature = "delegator-oauth")] @@ -101,6 +103,8 @@ pub fn builtin_pdps() -> Vec> { factories.push(Arc::new(cpex_pdp_cedar_direct::CedarDirectPdpFactory::new())); #[cfg(feature = "cel")] factories.push(Arc::new(cpex_pdp_cel::CelPdpFactory::new())); + #[cfg(feature = "opa")] + factories.push(Arc::new(cpex_pdp_opa::OpaPdpFactory::new())); factories } @@ -148,7 +152,9 @@ mod tests { #[test] fn pdp_factories_track_enabled_features() { - let expected = cfg!(feature = "cedar-direct") as usize + cfg!(feature = "cel") as usize; + let expected = cfg!(feature = "cedar-direct") as usize + + cfg!(feature = "cel") as usize + + cfg!(feature = "opa") as usize; assert_eq!( builtin_pdps().len(), expected, From 134e3c9bafca196e014b609f0b043d7a1e48eec1 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 00:34:35 -0400 Subject: [PATCH 03/10] test(pdp-opa): end-to-end visitor wiring; consolidate data loading Add an integration test that drives config through the apl-cpex visitor: an opa PDP declared in YAML with Rego modules and data, queried from a route step, producing real allow/deny decisions via the dispatcher. Covers the boolean allow/deny path, the deny-set idiom, external data lookup, load-time rejection of malformed config, and a missing-query author bug surfacing as a clean deny. Extract a merge_data helper so inline data and data files share one JSON normalization and error-mapping path. --- builtins/pdps/opa/src/resolver.rs | 40 +- builtins/pdps/opa/tests/visitor_opa_config.rs | 353 ++++++++++++++++++ 2 files changed, 373 insertions(+), 20 deletions(-) create mode 100644 builtins/pdps/opa/tests/visitor_opa_config.rs diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs index 3e5eb9ca..46beda3f 100644 --- a/builtins/pdps/opa/src/resolver.rs +++ b/builtins/pdps/opa/src/resolver.rs @@ -187,16 +187,7 @@ impl OpaResolver { // `.yaml` data file both work) and merged into the `data` root. if let Some(data) = map.get(serde_yaml::Value::String("data".into())) { if !data.is_null() { - let json = serde_json::to_string(data).map_err(|e| BuildError::DataParse { - name: "data".into(), - cause: e.to_string(), - })?; - engine - .add_data_json(&json) - .map_err(|e| BuildError::DataParse { - name: "data".into(), - cause: e.to_string(), - })?; + merge_data(&mut engine, "data", data)?; } } for path in read_string_seq(map, "data_files")? { @@ -209,16 +200,7 @@ impl OpaResolver { name: path.clone(), cause: e.to_string(), })?; - let json = serde_json::to_string(&parsed).map_err(|e| BuildError::DataParse { - name: path.clone(), - cause: e.to_string(), - })?; - engine - .add_data_json(&json) - .map_err(|e| BuildError::DataParse { - name: path, - cause: e.to_string(), - })?; + merge_data(&mut engine, &path, &parsed)?; } Ok(Self { @@ -393,6 +375,24 @@ impl PdpResolver for OpaResolver { } } +/// Normalize a YAML/JSON data document to JSON and merge it into the engine's +/// `data` root. `name` labels the source (`"data"` or a file path) in errors. +fn merge_data( + engine: &mut Engine, + name: &str, + value: &serde_yaml::Value, +) -> Result<(), BuildError> { + let to_err = |cause: String| BuildError::DataParse { + name: name.to_string(), + cause, + }; + let json = serde_json::to_string(value).map_err(|e| to_err(e.to_string()))?; + engine + .add_data_json(&json) + .map_err(|e| to_err(e.to_string()))?; + Ok(()) +} + /// Read an optional string field from a YAML mapping. fn read_string(map: &serde_yaml::Mapping, key: &str) -> Option { map.get(serde_yaml::Value::String(key.to_string()))? diff --git a/builtins/pdps/opa/tests/visitor_opa_config.rs b/builtins/pdps/opa/tests/visitor_opa_config.rs new file mode 100644 index 00000000..cc3b6e1c --- /dev/null +++ b/builtins/pdps/opa/tests/visitor_opa_config.rs @@ -0,0 +1,353 @@ +// Location: ./builtins/pdps/opa/tests/visitor_opa_config.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// End-to-end integration: a unified-config YAML that +// +// 1. declares an `opa` PDP under `global.apl.pdp[]` with Rego module(s), +// 2. attaches an `opa: { query: "..." }` policy step to a route, +// +// must flow a real decision from the cpex-core dispatcher through +// `AplConfigVisitor` → `PdpFactory` → `OpaResolver` → the regorus engine → +// back into the route handler's allow/deny split. +// +// This proves the wiring end-to-end. The crate's unit tests cover the +// bag→input mapping, config parsing, and the decision contract 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 `OpaPdpFactory` via `AplOptions.pdp_factories`. If this +// passes, an operator who drops an `opa` 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 cpex_pdp_opa::OpaPdpFactory; + +// A boolean allow-rule policy declared globally; the route queries it. The bag +// the cmf BagBuilder lifts from the SecurityExtension exposes `subject.id`, +// which the policy reads as `input.subject.id`. +const YAML: &str = r#" +global: + apl: + pdp: + - kind: opa + modules: + - | + package authz + default allow := false + allow if input.subject.id == "alice" +routes: + - tool: get_document + apl: + pre_invocation: + - opa: + query: data.authz.allow +"#; + +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. +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: opa` in YAML and finds this factory by key. + pdp_factories: vec![Arc::new(OpaPdpFactory::new())], + session_store_factories: Vec::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` satisfies the Rego `allow` rule → the query is `true` → Allow. +/// End-to-end: the visitor built the resolver from YAML, the route handler +/// dispatched the `opa:` step into it, regorus returned `true`, the pipeline +/// continues. +#[tokio::test] +async fn config_declared_opa_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 should satisfy the Rego allow rule; got violation = {:?}", + result.violation + ); +} + +/// `eve` does not match; the `allow` rule has a `default` of false → the query +/// is `false` → Deny halts the pipeline with a violation. +#[tokio::test] +async fn config_declared_opa_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 OPA PDP config (`on_error: maybe`) must be rejected at +/// `load_config_yaml` rather than discovered on first request. The +/// visitor → `OpaPdpFactory::build` → `OpaResolver::from_config` chain surfaces +/// `BuildError::ConfigShape` as a `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: opa + on_error: maybe + modules: + - "package authz\ndefault allow := false\n" +routes: + - tool: get_document + apl: + pre_invocation: + - opa: + query: data.authz.allow +"#; + 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}", + ); +} + +/// An `opa:` step with no `query` is an author bug the parser accepts opaquely; +/// the resolver only learns of it at request time. It must surface as a clean +/// Deny that halts the pipeline, never a panic. +#[tokio::test] +async fn missing_query_at_request_time_denies_without_panicking() { + const NO_QUERY_YAML: &str = r#" +global: + apl: + pdp: + - kind: opa + modules: + - "package authz\ndefault allow := false\n" +routes: + - tool: get_document + apl: + pre_invocation: + - opa: + on_deny: + - deny +"#; + let mgr = build_manager_with_yaml(NO_QUERY_YAML) + .await + .expect("an opa step without query 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-query opa step must halt the pipeline, not allow through", + ); + assert!( + result.violation.is_some(), + "missing-query dispatch error must surface as a violation", + ); +} + +/// The deny-set idiom works end-to-end: a `deny[msg]` set-valued query allows +/// when empty and denies when non-empty. This is the dominant OPA authoring +/// style, so proving it through the real dispatcher matters. +#[tokio::test] +async fn deny_set_idiom_end_to_end() { + const DENY_SET_YAML: &str = r#" +global: + apl: + pdp: + - kind: opa + modules: + - | + package authz + deny contains msg if { + input.subject.id != "alice" + msg := "subject not on the allowlist" + } +routes: + - tool: get_document + apl: + pre_invocation: + - opa: + query: data.authz.deny +"#; + let mgr = build_manager_with_yaml(DENY_SET_YAML) + .await + .expect("load_config_yaml"); + + // alice → empty deny set → allow. + let (allow, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload(), + Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &[]))), + ..Default::default() + }, + None, + ) + .await; + assert!( + allow.continue_processing, + "empty deny set must allow; got violation = {:?}", + allow.violation + ); + + // eve → non-empty deny set → deny. + let (deny, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload(), + Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("eve", &[]))), + ..Default::default() + }, + None, + ) + .await; + assert!( + !deny.continue_processing && deny.violation.is_some(), + "non-empty deny set must halt with a violation", + ); +} + +/// External `data` declared in the config is loaded into the engine's `data` +/// root and readable by policy — an operator can port a policy that splits +/// logic (modules) from lookup tables (data) with no rewrite. +#[tokio::test] +async fn external_data_is_readable_end_to_end() { + const DATA_YAML: &str = r#" +global: + apl: + pdp: + - kind: opa + modules: + - | + package authz + default allow := false + allow if "reader" in data.roles[input.subject.id] + data: + roles: + alice: [reader] +routes: + - tool: get_document + apl: + pre_invocation: + - opa: + query: data.authz.allow +"#; + let mgr = build_manager_with_yaml(DATA_YAML) + .await + .expect("load_config_yaml"); + + let (result, _bg) = mgr + .invoke_named::( + "cmf.tool_pre_invoke", + payload(), + Extensions { + meta: Some(Arc::new(meta_for_tool("get_document"))), + security: Some(Arc::new(security_with_roles("alice", &[]))), + ..Default::default() + }, + None, + ) + .await; + assert!( + result.continue_processing, + "alice has the reader role in the data document and should be allowed; \ + got violation = {:?}", + result.violation + ); +} From 9e4a1c62edafeeb092011c6586f1517742c5492a Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 00:47:32 -0400 Subject: [PATCH 04/10] fix(pdp-opa): stricter config parsing, docs, and coverage from review - Reject a non-string on_error/decision_field at load instead of silently defaulting, matching the strictness already applied to unknown keys and non-sequence modules. - Document OPA as a shipped default PDP builtin in the dialect table and README (it previously read as recognized-but-unimplemented). - Explain why the cache/coverage regorus features are enabled, matching the rationale given for the excluded network builtins. - Add coverage: a disabled network builtin cannot allow (pins the feature gate), module_files/data_files load-and-evaluate plus a missing data file, the dialect override, and the decision-object errors-key / id-fallback / allow-wins-over-violations paths. --- README.md | 2 +- builtins/pdps/opa/Cargo.toml | 3 + builtins/pdps/opa/src/decision.rs | 38 +++++++++ builtins/pdps/opa/src/resolver.rs | 128 ++++++++++++++++++++++++++++-- docs/content/docs/apl/pdp.md | 6 +- 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b0e696fc..3310ba34 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ CPEX is a Cargo workspace of focused crates: | [`cpex-builtins`](crates/cpex-builtins) | Feature-gated bundle of builtin plugins, PDPs, session stores | | [`cpex-ffi`](crates/cpex-ffi) | C FFI (`cdylib`/`staticlib`) for Go / Python / WASM host bindings | | [`apl-core`](crates/apl-core) · [`apl-cmf`](crates/apl-cmf) · [`apl-cpex`](crates/apl-cpex) | APL (Authorization Policy Language): compiler/evaluator, CMF bridge, CPEX integration | -| `builtins/*` | Bundled plugins (PII scanner, audit logger, JWT identity, OAuth/Biscuit delegation), PDPs (Cedar, CEL), and the Valkey session store | +| `builtins/*` | Bundled plugins (PII scanner, audit logger, JWT identity, OAuth/Biscuit delegation), PDPs (Cedar, CEL, OPA/Rego), and the Valkey session store | The C FFI is distributed as signed prebuilt artifacts. See [`crates/cpex-ffi/RELEASE.md`](crates/cpex-ffi/RELEASE.md). Go bindings live in [`go/cpex`](go/cpex). diff --git a/builtins/pdps/opa/Cargo.toml b/builtins/pdps/opa/Cargo.toml index 1eba4c75..f687b4d2 100644 --- a/builtins/pdps/opa/Cargo.toml +++ b/builtins/pdps/opa/Cargo.toml @@ -58,6 +58,9 @@ regorus = { version = "0.11", default-features = false, features = [ "urlquery", "uuid", "yaml", + # `cache` memoizes rule evaluation (a per-request eval speedup); `coverage` + # is a cheap, dependency-free flag kept for parity with regorus's pure-builtin + # set. Neither reaches network or runtime-introspection surfaces. "cache", "coverage", ] } diff --git a/builtins/pdps/opa/src/decision.rs b/builtins/pdps/opa/src/decision.rs index de5eb1c5..e4e95a9d 100644 --- a/builtins/pdps/opa/src/decision.rs +++ b/builtins/pdps/opa/src/decision.rs @@ -309,4 +309,42 @@ mod tests { Mapped::Degenerate(_) )); } + + #[test] + fn object_errors_key_lands_in_diagnostics() { + let m = map_query_result( + &val(r#"{"allow": false, "errors": ["policy failed", "missing role"]}"#), + "allow", + ); + match m { + Mapped::Decision(d) => { + assert!(d.diagnostics.iter().any(|x| x == "policy failed")); + assert!(d.diagnostics.iter().any(|x| x == "missing role")); + }, + Mapped::Degenerate(c) => panic!("degenerate: {c}"), + } + } + + #[test] + fn object_id_field_is_rule_source_fallback() { + let m = map_query_result(&val(r#"{"allow": false, "id": "rule-42"}"#), "allow"); + match decision_of(m) { + Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "rule-42"), + other => panic!("expected Deny, got {other:?}"), + } + } + + /// The decision field is authoritative: `allow: true` allows even if the + /// object also carries a populated `violations` list. Pins the documented + /// precedence so a future change to the object path is a conscious choice. + #[test] + fn allow_true_wins_over_populated_violations() { + assert_eq!( + decision_of(map_query_result( + &val(r#"{"allow": true, "violations": ["ignored"]}"#), + "allow" + )), + Decision::Allow + ); + } } diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs index 46beda3f..8148d6cc 100644 --- a/builtins/pdps/opa/src/resolver.rs +++ b/builtins/pdps/opa/src/resolver.rs @@ -143,7 +143,7 @@ impl OpaResolver { } } - let on_error = match read_string(map, "on_error").as_deref() { + let on_error = match read_string(map, "on_error")?.as_deref() { None | Some("deny") => OnError::Deny, Some("allow") => OnError::Allow, Some(other) => { @@ -153,7 +153,7 @@ impl OpaResolver { }, }; - let decision_field = read_string(map, "decision_field").unwrap_or_else(|| "allow".into()); + let decision_field = read_string(map, "decision_field")?.unwrap_or_else(|| "allow".into()); let mut engine = Engine::new(); @@ -393,11 +393,19 @@ fn merge_data( Ok(()) } -/// Read an optional string field from a YAML mapping. -fn read_string(map: &serde_yaml::Mapping, key: &str) -> Option { - map.get(serde_yaml::Value::String(key.to_string()))? - .as_str() - .map(|s| s.to_string()) +/// Read an optional string field from a YAML mapping. A key that is absent (or +/// explicitly null) yields `None`; a key present with a non-string value is a +/// config error rather than a silent default, matching the strictness applied +/// to unknown keys and non-sequence `modules`. +fn read_string(map: &serde_yaml::Mapping, key: &str) -> Result, BuildError> { + match map.get(serde_yaml::Value::String(key.to_string())) { + None => Ok(None), + Some(serde_yaml::Value::Null) => Ok(None), + Some(value) => match value.as_str() { + Some(s) => Ok(Some(s.to_string())), + None => Err(BuildError::ConfigShape(format!("`{key}` must be a string"))), + }, + } } /// Read an optional sequence-of-strings field. A missing key yields an empty @@ -506,6 +514,87 @@ data: let err = cfg("kind: opa\nmodules: not-a-list\n").unwrap_err(); assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("modules"))); } + + #[test] + fn non_string_on_error_is_rejected_not_silently_defaulted() { + // A non-string `on_error` (here a list) must fail loudly at load rather + // than silently falling back to the deny default. + let err = cfg("kind: opa\non_error: [deny]\n").unwrap_err(); + assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("on_error"))); + } + + #[test] + fn non_string_decision_field_is_rejected() { + let err = + cfg("kind: opa\ndecision_field: {a: 1}\nmodules:\n - \"package p\"\n").unwrap_err(); + assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("decision_field"))); + } + + /// A distinct temp file path per test, so parallel tests never collide. + fn temp_path(name: &str, ext: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("cpex_opa_{}_{}.{ext}", std::process::id(), name)) + } + + #[test] + fn module_files_load_and_evaluate() { + let path = temp_path("modfile", "rego"); + std::fs::write( + &path, + "package authz\ndefault allow := false\nallow if input.subject.id == \"alice\"\n", + ) + .unwrap(); + let yaml = format!("kind: opa\nmodule_files:\n - {}\n", path.display()); + let r = cfg(&yaml).expect("module_files should load"); + // Prove the loaded rule actually evaluates. + let mut engine = r.base_engine.clone(); + engine + .set_input_json(r#"{"subject":{"id":"alice"}}"#) + .unwrap(); + assert_eq!( + engine + .eval_rule("data.authz.allow".to_string()) + .unwrap() + .as_bool() + .copied() + .ok(), + Some(true) + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn data_files_load_and_are_readable() { + let path = temp_path("datafile", "json"); + std::fs::write(&path, r#"{"roles":{"alice":["reader"]}}"#).unwrap(); + let yaml = format!( + "kind: opa\nmodules:\n - |\n package authz\n default allow := false\n allow if \"reader\" in data.roles[input.subject.id]\ndata_files:\n - {}\n", + path.display() + ); + let r = cfg(&yaml).expect("data_files should load"); + let mut engine = r.base_engine.clone(); + engine + .set_input_json(r#"{"subject":{"id":"alice"}}"#) + .unwrap(); + assert_eq!( + engine + .eval_rule("data.authz.allow".to_string()) + .unwrap() + .as_bool() + .copied() + .ok(), + Some(true) + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn missing_data_file_names_the_path() { + let err = cfg("kind: opa\ndata_files:\n - /no/such/roles.json\n").unwrap_err(); + match err { + BuildError::DataFile { path, .. } => assert_eq!(path, "/no/such/roles.json"), + other => panic!("expected DataFile, got {other:?}"), + } + } } #[cfg(test)] @@ -766,4 +855,29 @@ msg := "not a decision" } } } + + #[test] + fn with_dialect_override_is_observable() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny) + .with_dialect(PdpDialect::Custom("opa-strict".into())); + assert_eq!(r.dialect(), PdpDialect::Custom("opa-strict".into())); + } + + /// Security gate: the `http.send` builtin is excluded from the pinned + /// regorus feature set, so an inline policy attempting network egress must + /// not succeed — it fails to compile/evaluate and denies. Guards against a + /// regorus upgrade or feature-set change silently re-enabling egress. + #[tokio::test] + async fn disabled_http_builtin_cannot_allow() { + let r = resolver(&[], OnError::Deny); + let inline = "package net\nallow if http.send({\"method\": \"get\", \"url\": \"http://example.com\"})\n"; + let out = r + .evaluate(&call("data.net.allow", Some(inline)), &bag("alice")) + .await + .unwrap(); + assert!( + matches!(out.decision, Decision::Deny { .. }), + "http.send must be unavailable and deny, not allow", + ); + } } diff --git a/docs/content/docs/apl/pdp.md b/docs/content/docs/apl/pdp.md index 69c16491..67356b86 100644 --- a/docs/content/docs/apl/pdp.md +++ b/docs/content/docs/apl/pdp.md @@ -51,17 +51,17 @@ global: ## Supported dialects -APL recognizes a fixed set of PDP dialects. Two ship as builtin resolvers; the rest are recognized by APL and dispatched to a resolver you provide on the host. +APL recognizes a fixed set of PDP dialects. Three ship as builtin resolvers; the rest are recognized by APL and dispatched to a resolver you provide on the host. | Dialect | Status | |---------|--------| | `cedar` | Ships as the `cedar-direct` builtin resolver. | | `cel` | Ships as the `cel` builtin resolver (safe, bounded expressions). | -| `opa` | Recognized dialect; wire a host resolver (Rego / OPA). | +| `opa` | Ships as the `opa` builtin resolver (embedded Rego via regorus, no sidecar). | | `authzen` | Recognized dialect; wire a host resolver (AuthZEN protocol). | | `nemo` | Recognized dialect; wire a host resolver (NeMo Guardrails). | -This is a deliberate pluggable-resolver surface, not a maturity checklist. APL speaks the dialect; the resolver is an implementation. Cedar and CEL are provided so you can start without writing one. For OPA, AuthZEN, or NeMo, implement the resolver trait and register it; the APL `opa:` / `authzen:` / `nemo:` call forms then work unchanged. +This is a deliberate pluggable-resolver surface, not a maturity checklist. APL speaks the dialect; the resolver is an implementation. Cedar, CEL, and OPA are provided so you can start without writing one. For AuthZEN or NeMo, implement the resolver trait and register it; the APL `authzen:` / `nemo:` call forms then work unchanged. CEL is the lightest option for inline boolean policy: From b381d7bd692ee7bea9c0941ea44769a998eff95c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 08:20:48 -0400 Subject: [PATCH 05/10] harden(pdp-opa): fail-closed inline-module isolation, cache cap, and eval offload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review findings on the embedded policy path: - Reject a route-step inline module whose Rego package collides with a global module's package (fail-closed). Inline modules may add new packages but can no longer merge into and override operator policy — closing an escalation path when route config is authored by a less-trusted party than global policy. - Treat a cache-full inline-module rejection as an always-deny condition rather than routing it through on_error, so a resource limit cannot fail open under on_error: allow. - Run set_input + eval_rule on a blocking thread so a CPU-heavy Rego evaluation cannot monopolize an async worker; a task panic fails closed. Adds a tokio runtime dependency (documented divergence from the inline-eval CEL resolver). - Document the inline-module trust boundary in the crate docs. --- Cargo.lock | 1 + builtins/pdps/opa/Cargo.toml | 12 +- builtins/pdps/opa/src/resolver.rs | 220 +++++++++++++++++++++++------- 3 files changed, 179 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f3bea08..9992d87f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "cpex-core", "cpex-pdp-cedar-direct", "cpex-pdp-cel", + "cpex-pdp-opa", "cpex-plugin-audit-logger", "cpex-plugin-delegator-oauth", "cpex-plugin-elicitation-ciba", diff --git a/builtins/pdps/opa/Cargo.toml b/builtins/pdps/opa/Cargo.toml index f687b4d2..6e9dda65 100644 --- a/builtins/pdps/opa/Cargo.toml +++ b/builtins/pdps/opa/Cargo.toml @@ -69,12 +69,20 @@ async-trait = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +# Rego evaluation is synchronous and can be CPU-heavy (comprehensions over +# large `data`, expensive builtins). The resolver runs each eval on +# `tokio::task::spawn_blocking` so one request cannot monopolize an async +# worker thread. This couples the crate to a tokio runtime at eval time — a +# deliberate divergence from `cpex-pdp-cel` (which evaluates inline), justified +# because Rego eval is materially heavier than a CEL predicate. The CPEX host +# already runs on tokio. +tokio = { workspace = true, features = ["rt"] } [dev-dependencies] # End-to-end integration tests wire the opa 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. +# only exist for tests — the crate depends only on apl-core (plus its engine +# and runtime deps) at compile time, so it can be used standalone. apl-cmf = { workspace = true } apl-cpex = { workspace = true } cpex-core = { workspace = true } diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs index 8148d6cc..abcefc0d 100644 --- a/builtins/pdps/opa/src/resolver.rs +++ b/builtins/pdps/opa/src/resolver.rs @@ -21,14 +21,28 @@ // at most once. The cache follows the workspace "cap + reject + log, never // evict" convention. // +// # Inline-module trust boundary +// +// A route-step inline module and the operator's global modules are both +// operator-authored config, but they can be edited by different parties (a +// central security team owns `global.pdp`; an app team may own a route). To +// keep an inline module from silently overriding operator policy, an inline +// module whose Rego package collides with a global-module package is rejected +// fail-closed (always deny) rather than merged. Inline modules may therefore +// *add* new packages but cannot redefine or extend a global package's rules. +// (Global modules still merge with each other per Rego package semantics — +// they share one trust level.) +// // # Decision contract // // The configured query must resolve to a boolean, a decision object, or a // set/array (see `crate::decision`). Fail-closed by default: an evaluation -// error routes through `on_error` (default `Deny`); a Rego parse error always -// denies. +// error routes through `on_error` (default `Deny`); a Rego parse error, an +// inline/global package collision, and a cache-full rejection always deny +// regardless of `on_error` (a compile-time or resource condition must never +// fail open). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::RwLock; use async_trait::async_trait; @@ -45,9 +59,10 @@ use crate::input::bag_to_input; /// What to do when a query errors at runtime or yields a value that carries no /// decision (a non-bool/object/set result, or a missing decision field). A /// `false`/deny result and an undefined result are NOT governed by this — they -/// are legitimate denials, always honored. Parse/compile errors are never -/// governed by this either: they always deny (an author bug must never flip to -/// allow). Mirrors `cpex-pdp-cel`'s `OnError`. +/// are legitimate denials, always honored. Parse/compile errors, inline/global +/// package collisions, and cache-full rejections are never governed by this +/// either: they always deny (an author bug, a trust-boundary violation, or a +/// resource limit must never flip to allow). Mirrors `cpex-pdp-cel`'s `OnError`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum OnError { /// Fail-closed: a degenerate runtime outcome denies. The APL default. @@ -82,6 +97,10 @@ pub struct OpaResolver { /// The base engine, prepared once with all global modules + data. Cloned /// per request (cheap — compiled policy/data is `Arc`-shared). base_engine: Engine, + /// Rego package paths declared by the global modules (e.g. `data.authz`), + /// captured at build. A route-step inline module whose package is in this + /// set is rejected fail-closed so it cannot override operator policy. + global_packages: HashSet, /// Cache of prepared engines for inline modules, keyed by module source. /// `RwLock` so the steady-state read path is uncontended once a route's /// inline module has been prepared. @@ -156,30 +175,37 @@ impl OpaResolver { let decision_field = read_string(map, "decision_field")?.unwrap_or_else(|| "allow".into()); let mut engine = Engine::new(); + // Track each global module's Rego package (the path `add_policy` + // returns) so an inline step module can be rejected if it collides. + let mut global_packages = HashSet::new(); // 1. Global modules — inline texts first, then files. Each gets a // unique virtual name so same-package modules merge (Rego // semantics) rather than one overwriting another by filename. for (module_index, text) in read_string_seq(map, "modules")?.into_iter().enumerate() { let name = format!("global-{module_index}.rego"); - engine - .add_policy(name.clone(), text) - .map_err(|e| BuildError::ModuleParse { - name, - cause: e.to_string(), - })?; + let package = + engine + .add_policy(name.clone(), text) + .map_err(|e| BuildError::ModuleParse { + name, + cause: e.to_string(), + })?; + global_packages.insert(package); } for path in read_string_seq(map, "module_files")? { let text = std::fs::read_to_string(&path).map_err(|source| BuildError::ModuleFile { path: path.clone(), source, })?; - engine - .add_policy(path.clone(), text) - .map_err(|e| BuildError::ModuleParse { - name: path, - cause: e.to_string(), - })?; + let package = + engine + .add_policy(path.clone(), text) + .map_err(|e| BuildError::ModuleParse { + name: path, + cause: e.to_string(), + })?; + global_packages.insert(package); } // 2. Data documents — inline mapping first, then files. Both are @@ -208,6 +234,7 @@ impl OpaResolver { on_error, decision_field, base_engine: engine, + global_packages, inline_cache: RwLock::new(HashMap::new()), max_cache_entries: DEFAULT_MAX_CACHE_ENTRIES, }) @@ -250,10 +277,17 @@ impl OpaResolver { // Prepare base + inline module. A parse failure here is a compile error // (always denies), not a runtime condition. let mut engine = self.base_engine.clone(); - engine + let package = engine .add_policy(INLINE_MODULE_NAME.to_string(), src.to_string()) .map_err(|e| EngineError::Compile(e.to_string()))?; + // Reject an inline module that lands in a global module's package — it + // would merge into (and could override) operator policy. Fail-closed: + // inline modules may add new packages, never redefine a global one. + if self.global_packages.contains(&package) { + return Err(EngineError::PackageCollision(package)); + } + // Insert under the cap — reject past it, never evict (workspace cache // convention). let mut cache = self.inline_cache.write().unwrap_or_else(|p| p.into_inner()); @@ -272,9 +306,11 @@ impl OpaResolver { Ok(engine) } - /// Apply `on_error` to a degenerate RUNTIME outcome (eval error, a value - /// carrying no decision, or a cache-full rejection). Allow logs at `error!` - /// so a misused fail-open flag is never silent in production. + /// Apply `on_error` to a degenerate RUNTIME outcome (an eval error or a + /// value carrying no decision). Allow logs at `error!` so a misused + /// fail-open flag is never silent in production. (Compile errors, package + /// collisions, and cache-full rejections do NOT come through here — they + /// always deny via `compile_error_decision`.) fn on_error_decision(&self, cause: String) -> PdpDecision { match self.on_error { OnError::Allow => { @@ -298,12 +334,14 @@ impl OpaResolver { } } - /// A Rego compile error always denies, regardless of `on_error` — malformed - /// policy is an author bug and must never flip to allow. + /// Always deny, regardless of `on_error`, for a non-negotiable failure: a + /// Rego compile error (author bug), an inline/global package collision + /// (trust-boundary violation), or a cache-full rejection (resource limit). + /// None of these is a policy outcome that may fail open. fn compile_error_decision(&self, cause: String) -> PdpDecision { tracing::error!( cause = %cause, - "OPA compile error — denying the request regardless of on_error mode." + "OPA fail-closed condition — denying the request regardless of on_error mode." ); PdpDecision { decision: Decision::Deny { @@ -315,15 +353,31 @@ impl OpaResolver { } } -/// Internal — failure shapes from preparing a per-step engine. +/// Internal — failure shapes from preparing a per-step engine. All three +/// always deny regardless of `on_error`: a compile error is an author bug, a +/// package collision is a trust-boundary violation, and a cache-full condition +/// is a resource limit — none is a legitimate policy outcome that should be +/// allowed to fail open. enum EngineError { - /// A Rego parse/compile error in an inline module. Always denies. + /// A Rego parse/compile error in an inline module. Compile(String), - /// The inline-module cache hit its cap. A runtime resource limit routed - /// through `on_error`, matching the CEL resolver's cache-full handling. + /// The inline module's package collides with a global-module package. + PackageCollision(String), + /// The inline-module cache hit its cap. CacheFull { cap: usize }, } +/// Outcome of the blocking evaluation task. Held as an owned, `Send` value so +/// it can cross the `spawn_blocking` boundary; `on_error` is applied by the +/// caller (which needs `&self` for logging). +enum EvalOutcome { + /// A terminal decision the contract produced directly. + Decision(PdpDecision), + /// A degenerate runtime outcome (eval error, non-decision value) whose + /// disposition depends on `on_error`. + OnError(String), +} + #[async_trait] impl PdpResolver for OpaResolver { fn dialect(&self) -> PdpDialect { @@ -344,33 +398,55 @@ impl PdpResolver for OpaResolver { .and_then(|m| m.get(serde_yaml::Value::String("module".into()))) .and_then(|v| v.as_str()); - // 2. Prepare the engine. Compile errors always deny; a cache-full - // rejection routes through on_error. - let mut engine = match self.engine_for(module) { + // 2. Prepare the engine. A compile error, a package collision, and a + // cache-full rejection all always deny — none may fail open. + let engine = match self.engine_for(module) { Ok(engine) => engine, Err(EngineError::Compile(cause)) => { return Ok(self.compile_error_decision(format!("OPA inline module: {cause}"))); }, + Err(EngineError::PackageCollision(package)) => { + return Ok(self.compile_error_decision(format!( + "OPA inline module package `{package}` collides with a global-module \ + package; inline modules may not override global policy" + ))); + }, Err(EngineError::CacheFull { cap }) => { - return Ok(self.on_error_decision(format!( + return Ok(self.compile_error_decision(format!( "OPA inline-module cache full (cap={cap}); refusing a new module" ))); }, }; - // 3. Map the bag into the Rego `input` document. - let input = bag_to_input(bag); - if let Err(e) = engine.set_input_json(&input.to_string()) { - return Ok(self.on_error_decision(format!("OPA failed to set input: {e}"))); - } - - // 4. Evaluate the query and map the result to a decision. - match engine.eval_rule(query.to_string()) { - Ok(value) => match map_query_result(&value, &self.decision_field) { - Mapped::Decision(decision) => Ok(decision), - Mapped::Degenerate(cause) => Ok(self.on_error_decision(cause)), + // 3. Map the bag into the Rego `input` document, then set input and + // evaluate on a blocking thread — Rego eval is synchronous and can + // be CPU-heavy, and must not monopolize an async worker. + let input_json = bag_to_input(bag).to_string(); + let query = query.to_string(); + let decision_field = self.decision_field.clone(); + let outcome = tokio::task::spawn_blocking(move || { + let mut engine = engine; + if let Err(e) = engine.set_input_json(&input_json) { + return EvalOutcome::OnError(format!("OPA failed to set input: {e}")); + } + match engine.eval_rule(query) { + Ok(value) => match map_query_result(&value, &decision_field) { + Mapped::Decision(decision) => EvalOutcome::Decision(decision), + Mapped::Degenerate(cause) => EvalOutcome::OnError(cause), + }, + Err(e) => EvalOutcome::OnError(format!("OPA eval error: {e}")), + } + }) + .await; + + // 4. Apply on_error to degenerate outcomes; a task panic fails closed + // through on_error too. + match outcome { + Ok(EvalOutcome::Decision(decision)) => Ok(decision), + Ok(EvalOutcome::OnError(cause)) => Ok(self.on_error_decision(cause)), + Err(join_err) => { + Ok(self.on_error_decision(format!("OPA evaluation task failed: {join_err}"))) }, - Err(e) => Ok(self.on_error_decision(format!("OPA eval error: {e}"))), } } } @@ -770,16 +846,35 @@ msg := "not a decision" } } - /// A same-package inline module merges with a global module (Rego - /// semantics) — no error on package reuse — and can add a rule. + /// An inline module whose package collides with a global-module package is + /// rejected fail-closed — it must not be able to merge into (and override) + /// operator policy, even under on_error: allow. + #[tokio::test] + async fn inline_module_cannot_override_global_package() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Allow); + // A hostile inline module tries to force allow in the operator's `authz` + // package. It shares the package, so it is rejected → deny. + let inline = "package authz\nallow if true\n"; + let out = r + .evaluate(&call("data.authz.allow", Some(inline)), &bag("eve")) + .await + .unwrap(); + match out.decision { + Decision::Deny { reason, .. } => { + assert!(reason.unwrap_or_default().contains("collides")); + }, + other => panic!("package collision must deny, got {other:?}"), + } + } + + /// An inline module in a fresh package (no global collision) is accepted and + /// evaluates — inline modules remain a usable feature for additive policy. #[tokio::test] - async fn inline_module_merges_with_global_package() { + async fn inline_module_in_new_package_is_allowed() { let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); - // Add a rule in the same `authz` package via an inline module and query - // it. The merge must succeed (not error), and the new rule evaluates. - let inline = "package authz\nextra if input.subject.id == \"alice\"\n"; + let inline = "package extra\nallow if input.subject.id == \"alice\"\n"; let out = r - .evaluate(&call("data.authz.extra", Some(inline)), &bag("alice")) + .evaluate(&call("data.extra.allow", Some(inline)), &bag("alice")) .await .unwrap(); assert_eq!(out.decision, Decision::Allow); @@ -827,6 +922,27 @@ msg := "not a decision" assert_eq!(again.decision, Decision::Allow); } + /// A cache-full rejection is a resource limit, not a policy outcome, so it + /// denies even under on_error: allow — it must not fail open. + #[tokio::test] + async fn inline_cache_cap_denies_even_under_on_error_allow() { + let r = resolver(&[], OnError::Allow).with_max_cache_entries(1); + let m1 = "package a\nallow if input.subject.id == \"alice\"\n"; + let m2 = "package b\nallow if input.subject.id == \"alice\"\n"; + let _ = r + .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) + .await + .unwrap(); + let out = r + .evaluate(&call("data.b.allow", Some(m2)), &bag("alice")) + .await + .unwrap(); + assert!( + matches!(out.decision, Decision::Deny { .. }), + "cache-full must deny even with on_error: allow", + ); + } + /// Many threads sharing one `Arc` evaluate concurrently and /// each gets the correct per-request decision (exercises clone-per-request /// under the `arc` feature). From 2d93fc9d7b887059453fb0252502fbd6c4d5cfa4 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 08:21:58 -0400 Subject: [PATCH 06/10] docs(pdp-opa): add requirements and implementation plan for the OPA/Rego PDP Capture the brainstorm requirements doc and the implementation plan (marked completed) that drove this feature. --- .../opa-rego-pdp-builtin-requirements.md | 124 ++++++ .../2026-07-28-001-feat-opa-rego-pdp-plan.md | 378 ++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 docs/brainstorms/opa-rego-pdp-builtin-requirements.md create mode 100644 docs/plans/2026-07-28-001-feat-opa-rego-pdp-plan.md diff --git a/docs/brainstorms/opa-rego-pdp-builtin-requirements.md b/docs/brainstorms/opa-rego-pdp-builtin-requirements.md new file mode 100644 index 00000000..152e0743 --- /dev/null +++ b/docs/brainstorms/opa-rego-pdp-builtin-requirements.md @@ -0,0 +1,124 @@ +--- +date: 2026-07-28 +topic: opa-rego-pdp-builtin +--- + +# OPA/Rego PDP Builtin (regorus) + +## Summary + +Add an embedded OPA/Rego PDP builtin (`cpex-pdp-opa`), backed by the pure-Rust `regorus` interpreter, that evaluates Rego policy in-process and returns allow/deny — a fourth builtin PDP alongside cedar-direct, cel, and external OPA. Rego modules are declared globally and/or inline per route step; each step names the query; the request `AttributeBag` becomes the Rego `input`; external `data` tables load at build; and when a policy returns a rich decision object, its reason and violation fields flow into the CPEX policy violation on Deny. + +--- + +## Problem Frame + +Operators who have standardized on OPA/Rego have no in-process option in CPEX today. Their only choices are to rewrite policy in Cedar or CEL, or stand up an external OPA server and pay a network hop plus a sidecar to run on every request. Teams with existing, versioned Rego policy sets cannot reuse them without either a rewrite or operational overhead that the other builtin PDPs (cedar-direct, cel) do not impose. The gap is felt most by operators porting an established Rego policy base into a deployment model that otherwise runs entirely in-process. + +`PdpDialect::Opa` already exists in apl-core but has no resolver behind it, so the dialect is declarable yet non-functional. + +--- + +## Actors + +- A1. Operator: owns the deployment. Configures the `global.pdp` block — declares the `opa` PDP, loads shared modules and external `data`, sets `on_error` and the decision-field name. +- A2. Policy author: writes the Rego modules and chooses each route step's query. May be the same person as the operator or a separate policy team owning a versioned policy set. + +--- + +## Key Flows + +- F1. Evaluate a request against embedded Rego + - **Trigger:** A route with an `opa:` step reaches the PDP stage during request handling. + - **Actors:** A1 (configured the engine), A2 (authored the policy) + - **Steps:** The resolver maps the request `AttributeBag` into the Rego `input` document; resolves the step's inline module (if any) on top of the globally prepared modules and `data`; evaluates the step's query; interprets the result (boolean, or decision object) into allow/deny. + - **Outcome:** The pipeline continues on allow, or raises a CPEX policy violation on deny carrying the policy's reason and any violations. + - **Covered by:** R1, R4, R5, R6, R7, R8, R9 + +- F2. Load policy at configuration time + - **Trigger:** Host calls `load_config_yaml`; the apl-cpex visitor finds a `kind: opa` block and dispatches to the factory. + - **Actors:** A1 + - **Steps:** The factory reads the block, parses and prepares the global modules and `data` once, constructs the resolver. A parse error in a global module surfaces at load time. + - **Outcome:** A ready `OpaResolver` is registered; the operator sees bad global policy at deploy time, not first request. + - **Covered by:** R2, R3, R10 + +--- + +## Requirements + +**Crate and wiring** +- R1. New crate `cpex-pdp-opa` providing `OpaResolver` implementing `PdpResolver` with `dialect() -> PdpDialect::Opa`, and `OpaPdpFactory` implementing `PdpFactory` with `kind() = "opa"`, built from the `global.pdp` config block. Mirrors the shape of `cpex-pdp-cel`. +- R2. The crate is apl-core-only at compile time; apl-cpex and cpex-core are dev-dependencies used only for end-to-end tests, matching cel and cedar-direct. +- R3. An optional `opa` feature in `cpex-builtins` pulls exactly this one crate (one feature → one crate), is registered in `builtin_pdps`, and is added to the `default` feature set. + +**Policy source (hybrid)** +- R4. Rego modules may be declared in the global `pdp` block as module text and/or files. Global modules are parsed and prepared once at factory build (compile-once / eval-many). +- R5. Rego modules may also be supplied inline in an `opa:` route step. Inline modules are compiled and cached per distinct module source, with a bounded cache using the workspace "cap + reject + log, never evict" convention. +- R6. Global and inline modules are additive for a step's evaluation. A package-name collision between a global and an inline module is a loud fail-closed error, not a silent override. + +**Inputs and data** +- R7. The request `AttributeBag` maps to the Rego `input` document, reusing the bag→nested-namespace mapping the CEL resolver already applies (flat dotted keys become nested `input` fields). +- R8. The global block can load external `data` documents (inline JSON and/or files) into the engine's `data` root at build, so policies can read static lookup tables (e.g. `data.roles[input.subject.id]`). + +**Decision contract** +- R9. Each `opa:` step carries a required `query` (e.g. `data.authz.allow`); there is no implicit default query. +- R10. The query result may be a bare boolean or a decision object. A bare boolean is allow/deny directly. For an object, the allow/deny boolean is read from a decision field named `allow` by default, overridable in the config block. +- R11. On Deny, when the query returned a decision object, its human-readable fields enrich the CPEX policy violation: a `reason` or `message` string becomes the Deny `reason`; a `violations` or `errors` list appends to `PdpDecision.diagnostics`; a policy-supplied id field becomes the `rule_source` (else `"opa"`); and any remaining object fields are serialized into diagnostics so nothing the author returned is dropped. This reuses the same attribution channel cedar-direct uses for its policy-id reasons. +- R12. `on_error: deny | allow` governs degenerate runtime outcomes (undefined query result, non-boolean/absent decision field, eval error), defaulting to `deny`. A Rego parse/compile error always denies regardless of `on_error`, matching CEL's compile-vs-runtime asymmetry. + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R4, R9, R10.** Given a route with an `opa:` step and a Rego module that allows the request, when the PDP step evaluates, then the resolver returns allow and the pipeline continues. +- AE2. **Covers R12.** Given an `opa` PDP configured with `on_error: deny`, when policy evaluation errors, then the request is denied. +- AE3. **Covers R11.** Given a policy whose query returns `{ "allow": false, "reason": "subject not in allowlist", "violations": [...] }`, when the step evaluates, then the request is denied and the Deny reason carries "subject not in allowlist" with the violations in diagnostics. +- AE4. **Covers R10, R12.** Given a query that resolves to a non-boolean and no recognized decision field, when the step evaluates under default `on_error`, then the outcome is treated as degenerate and denied. +- AE5. **Covers R12.** Given a global module with a Rego syntax error, when the config loads, then the parse error surfaces at configuration time (and any request that reaches such policy denies regardless of `on_error`). +- AE6. **Covers R8.** Given external `data` declaring `roles.alice = ["reader"]` and a policy that allows when the subject holds `reader`, when subject `alice` makes a request, then the request is allowed. + +--- + +## Success Criteria + +- An operator with an existing Rego policy set can drop a `kind: opa` block plus route `opa:` steps into their config and get in-process allow/deny decisions with no sidecar, no network hop, and no rewrite. +- Denials produced by rich Rego decision objects are legible in CPEX violations — the reason and violations reach the operator without re-running the policy under debug. +- A downstream implementer can build the crate from this doc without inventing product behavior: the policy-source model, input/data mapping, decision contract, and error semantics are all specified and anchored to existing cel/cedar precedent. + +--- + +## Scope Boundaries + +- External/remote OPA over HTTP, OPA bundles, and the OPA management API — this is the embedded path only; remote OPA remains its own dialect. +- Hot-reloading modules or `data` at runtime — configuration is loaded once at build, same as cedar-direct. +- Structured obligations/advice semantics beyond reason and violations text — v1 surfaces them as diagnostics but does not enforce obligations. +- Rego `print`/trace debug surfaces and custom builtin registration beyond what regorus provides by default. + +--- + +## Key Decisions + +- Hybrid policy source (global + inline modules) rather than global-only (cedar) or inline-only (cel): supports both central versioned policy sets and quick per-route rules, at the cost of a larger config and test surface than either single model. +- Decision contract accepts an object, not just a boolean: lets rich Rego decisions map onto the existing `Decision::Deny { reason, rule_source }` + `diagnostics` fields, giving operators legible violations instead of an opaque deny — the same benefit cedar's policy-id attribution provides. +- Default decision field `allow`, overridable: matches the most common Rego convention (`default allow = false`) while not forcing it on policies that name the field differently. +- `regorus` (Apache-2.0, pure Rust): no sidecar, no C/network dependency, and no `deny.toml` license allow-list change. +- Fail-closed defaults mirroring cel/cedar: `on_error` defaults to deny; parse/compile errors are never flippable to allow. + +--- + +## Dependencies / Assumptions + +- `PdpDialect::Opa` and `PdpDialect::from_key("opa")` already exist in apl-core (verified) and only need a resolver behind them. +- `regorus` is not yet in the workspace `Cargo.lock` (verified absent) and will be added as a dependency of the new crate. +- The bag→nested-namespace mapping in the cel resolver's `activation.rs` is a reusable template for building the Rego `input` document; the exact reuse mechanism (shared helper vs. crate-local port) is a planning decision. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R7][Technical] Whether the bag→`input` mapping is factored into a shared helper or ported into the opa crate, and how `StringSet`/float/int types translate into regorus values. +- [Affects R5, R6][Technical] Exact inline-module cache key and the precise collision-detection point (load vs. eval) for global-vs-inline package clashes. +- [Affects R11][Needs research] regorus's result surface for object-valued queries and undefined results — how to distinguish "undefined" from "false" from "object" in its return type. +- [Affects R8][Technical] Config shape for `data` (inline map vs. `data_files` list) and file-path resolution relative to the config. diff --git a/docs/plans/2026-07-28-001-feat-opa-rego-pdp-plan.md b/docs/plans/2026-07-28-001-feat-opa-rego-pdp-plan.md new file mode 100644 index 00000000..265078d1 --- /dev/null +++ b/docs/plans/2026-07-28-001-feat-opa-rego-pdp-plan.md @@ -0,0 +1,378 @@ +--- +title: "feat: OPA/Rego PDP builtin (regorus)" +type: feat +status: completed +date: 2026-07-28 +deepened: 2026-07-29 +origin: docs/brainstorms/opa-rego-pdp-builtin-requirements.md +--- + +# feat: OPA/Rego PDP builtin (regorus) + +## Summary + +Build a new `cpex-pdp-opa` crate that evaluates Rego in-process via `regorus`, mirroring `cpex-pdp-cel`'s layout. The resolver prepares a base `regorus::Engine` at factory build (global modules + `data`), then clones it per request to set `input` and evaluate the query — sidestepping regorus's `&mut self` eval with cheap Rc/Arc-backed clones. Inline modules use a bounded inline-module cache of prepared engines; wiring touches the root `Cargo.toml`, `cpex-builtins`, and nothing in apl-core. + +--- + +## Problem Frame + +Operators standardized on OPA/Rego have no in-process PDP option in CPEX today — only Cedar, CEL, or an external OPA server over HTTP. `PdpDialect::Opa` exists in apl-core but has no resolver behind it. See origin for the full motivation and product decisions (`docs/brainstorms/opa-rego-pdp-builtin-requirements.md`). + +--- + +## Requirements + +**Crate and wiring** +- R1. New crate `cpex-pdp-opa` with `OpaResolver` (`dialect() -> PdpDialect::Opa`) and `OpaPdpFactory` (`kind() = "opa"`), built from the `global.pdp` block. (origin R1) +- R2. Crate is apl-core-only at compile time; apl-cpex/cpex-core are dev-deps for e2e tests. (origin R2) +- R3. Optional `opa` feature in `cpex-builtins` pulls exactly this crate, registered in `builtin_pdps`, added to `default`. (origin R3) + +**Policy source (hybrid)** +- R4. Modules declarable globally (text and/or files), parsed once at factory build. (origin R4) +- R5. Modules also suppliable inline in the `opa:` step, compiled-cached per distinct source with a bounded cap+reject+log cache. (origin R5) +- R6. Global + inline modules are additive and merge per Rego package semantics (same-package modules combine, as in OPA); the resolver does not treat package reuse as an error. (origin R6, reframed — Rego package merge is legitimate, not a collision) + +**Inputs and data** +- R7. `AttributeBag` → Rego `input`, reusing CEL's nested-namespace mapping. (origin R7) +- R8. Global block loads external `data` (inline JSON and/or files) into the engine's `data` root at build. (origin R8) + +**Decision contract** +- R9. Each `opa:` step carries a required `query`; no implicit default. (origin R9) +- R10. Query result may be a bare boolean, a decision object, or a set/array. Boolean is allow/deny directly. An object's allow/deny bit comes from a field named `allow` by default, overridable in config. A set/array follows the deny-set/violation-set idiom: empty → allow, non-empty → deny (elements become violations). (origin R10, extended to cover set/array so `deny[msg]`-style policies work without a wrapping query) +- R11. On Deny from a decision object, reason/message → Deny `reason`; violations/errors list → diagnostics; policy-supplied id → `rule_source` (else `"opa"`); remaining fields serialized into diagnostics. For a set/array deny, the elements become the violations in diagnostics. (origin R11) +- R12. `on_error: deny | allow` (default deny) governs genuine runtime errors and non-boolean/non-object/non-set degenerate values; parse/compile errors always deny. An undefined query result is a clean deny independent of `on_error` (matching OPA's undefined-is-not-granted semantics), not a degenerate outcome. (origin R12) + +**Origin actors:** A1 (operator — configures global block), A2 (policy author — writes Rego + query) +**Origin flows:** F1 (evaluate a request against embedded Rego), F2 (load policy at configuration time) +**Origin acceptance examples:** AE1 (allow), AE2 (deny on error), AE3 (rich object deny), AE4 (non-boolean degenerate), AE5 (parse error at load), AE6 (external data) + +--- + +## Scope Boundaries + +- External/remote OPA over HTTP, bundles, management API — embedded path only. +- Hot-reloading modules or `data` at runtime — config loaded once at build. +- Structured obligations/advice enforcement beyond reason + violations text. +- Rego `print`/trace debug surfaces and custom builtin registration beyond regorus defaults. + +### Deferred to Follow-Up Work + +- Merging extra `opa:` step keys (beyond `query`/`module`) into the `input` document: future iteration — v1 `input` is bag-derived only. +- regorus `compile_with_entrypoint`/`CompiledPolicy` precompiled-artifact path: revisit only if profiling shows clone-per-request is too costly. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `builtins/pdps/cel/src/lib.rs`, `resolver.rs`, `factory.rs`, `activation.rs`, `error.rs` — the closest template for crate layout, compile cache (`DEFAULT_MAX_CACHE_ENTRIES`, cap+reject+log), `on_error`, bag→nested mapping, and the `KNOWN_KEYS` config-rejection loop + `on_error` parse (`resolver.rs:215-249`). +- `builtins/pdps/cedar-direct/src/resolver.rs:118-172` — text-vs-file tuple pattern for `policy_text`/`policy_file` and `schema_text`/`schema_file`; `read_yaml_string` helper (`:262-266`). Mirror for `modules`/`module_files` and `data`/`data_files`. +- `builtins/pdps/cedar-direct/src/decision.rs:53-123` — decision mapping: `diagnostics` = full firing vector, `rule_source` = first (with fallback), `reason` = `Some(String)`. +- `builtins/pdps/cedar-direct/src/error.rs:26-67` — `BuildError` shape (`ConfigShape`, `PolicyFile { path, source }`, `PolicyParse`, etc.). +- `crates/apl-core/src/evaluator.rs:26-35` — `Decision::{Allow, Deny { reason: Option, rule_source: String }}`. +- `crates/apl-core/src/step.rs` — `PdpDecision { decision, diagnostics: Vec }` (`:808`), `PdpResolver`/`PdpFactory` traits (`:337`, `:364`), `PdpDialect::Opa` + `from_key("opa")` already present (`:300`, `:322`). +- `crates/apl-core/src/attributes.rs:32-40, 79-159` — `AttributeValue::{Bool,Int,Float,String,StringSet}`; `AttributeBag::iter()` yields `(&str, &AttributeValue)`. +- `crates/cpex-builtins/src/lib.rs:37-40, 98-105, 149-157` — re-export, `builtin_pdps()` pushes, factory-count test — every wiring edit site. +- `builtins/pdps/cel/tests/visitor_cel_config.rs` — full e2e harness (`build_manager_with_yaml` at `:83-106`, `AplOptions` field set, request construction, allow/deny assertions, negative config test at `:169-194`). + +### Institutional Learnings + +- No `docs/solutions/` knowledge base exists in this repo; the cel/cedar crates are the authoritative prior art. (A scratch note `.sketchpad/new_issue_opa-pdp.md` exists but is an unvetted draft.) + +### External References + +- regorus 0.11.0 (crates.io, docs.rs). Tri-licensed `MIT AND Apache-2.0 AND BSD-3-Clause` — Apache-2.0 is on `deny.toml`'s allow-list; pure Rust in the default build; published on crates.io (satisfies `deny.toml`'s crates.io-only source rule). +- Key API facts driving the design: + - `Engine::new()`; `add_policy(path: String, rego: String) -> Result` (incremental, `&mut self`); `add_data_json(&str) -> Result<()>` / `add_data(Value)` (merges, conflicts error). + - `set_input(Value)` / `set_input_json(&str)` and **all `eval_*` methods take `&mut self`** — no shared-immutable eval path. + - `Engine: Clone`, cheap (Rc/Arc-backed); the `arc` feature makes it `Send`/`Sync`. + - `eval_rule("data.authz.allow".into()) -> Result`; non-matching query → `Value::Undefined` (distinct from `Value::Bool(false)`). + - `Value::{Null,Bool,Number,String,Array,Set,Object,Undefined}` with `as_bool`/`as_object`/`as_string`/`to_json_str` helpers. + - Errors are type-erased `anyhow::Error`: parse/compile errors surface at `add_policy` time, runtime errors at `eval` time — distinguishable only by call site. + +--- + +## Key Technical Decisions + +- **Base engine + clone-per-request concurrency model.** The resolver holds one base `Engine` prepared with global modules + `data`. `evaluate` clones the base, sets input, evaluates. Chosen over a mutex (serializes requests) or an engine pool (added complexity) because clones are Rc/Arc-cheap. Requires enabling regorus's `arc` feature so `OpaResolver` is `Send`/`Sync` as `PdpResolver` demands. +- **`eval_rule` as the evaluation entry point**, not `eval_bool_query`/`eval_allow_query`, because it returns `Value::Undefined` distinctly — required to separate undefined from a legitimate `false`. +- **Parse-vs-runtime classification by call site.** Since regorus uses `anyhow`, we cannot match error variants. A failure at `add_policy` time is a compile error → always deny (mirrors CEL's `compile_error_decision`); a failure at `eval` time is a runtime error → routes through `on_error`. Global modules therefore fail at config load (fail-fast startup); inline modules fail at cache-build. +- **Undefined → clean Deny, independent of `on_error`.** An undefined query result is Rego's idiomatic "not granted" path (an `allow` rule with no `default` that didn't match), so it denies unconditionally rather than routing through `on_error`. Routing it through `on_error` would let `on_error: allow` flip an ordinary non-match to Allow, inverting Rego's safe-by-default semantics. `on_error` is reserved for genuine eval errors and non-bool/non-object/non-set degenerate values. +- **Decision contract accepts set/array (deny-set idiom).** Beyond boolean and object, a set/array result is interpreted empty-means-allow / non-empty-means-deny, so `deny[msg]`/`violation[...]` policies (Gatekeeper, conftest) work as-is without a wrapping `allow { count(deny)==0 }` query. This preserves the "drop in existing Rego, no rewrite" success criterion for the dominant Rego authoring style. +- **Package reuse is not a collision.** Global and inline modules merge per Rego package semantics (same-package modules combine, as OPA does natively); the resolver does not treat package-name reuse as an error, because that is normal Rego, not a fault. +- **Planning IDs stay out of the codebase.** R#/U#/AE#/F# identifiers and references to this plan or the origin requirements doc must not leak into commit messages, source, rustdocs, or test names — the code and its history must read as self-contained. Traceability lives in this plan only. +- **Bag→input logic ported from CEL's `activation.rs`** into the opa crate (not a shared helper — keeps the crate apl-core-only and avoids coupling), emitting a regorus input object and preserving CEL's StringSet→sorted-array and whole-float→int coercions. +- **Config parsing = cedar's text-vs-file tuple matching + CEL's `KNOWN_KEYS` rejection + `on_error` match**, extended to `["kind", "modules", "module_files", "data", "data_files", "decision_field", "on_error"]`. +- **regorus pinned with `default-features = false` + explicit feature allow-list.** regorus's `default` (`full-opa`) enables `http.send`, `net`, `opa.runtime`, `jsonschema`, and a `mimalloc` C allocator. The PDP opts out and enables only what it needs, keeping the build pure-Rust (so the `deny.toml` license posture holds) and denying inline policy authors network/environment builtins — mirroring the CEL crate's explicit-feature rationale. The `http`/`net`/`opa-runtime` exclusion is a security default, not a size optimization. + +--- + +## Open Questions + +### Resolved During Planning + +- Concurrency under regorus's `&mut self` eval: resolved — base engine + clone-per-request with the `arc` feature. +- How to distinguish undefined from false: resolved — `eval_rule` returns `Value::Undefined`. +- Parse vs runtime error classification: resolved — by call site, not error variant. +- regorus license/source acceptability: resolved — Apache-2.0, crates.io, no `deny.toml` change (pure-Rust build preserved by pinning `default-features = false`, which drops the `mimalloc` C allocator). +- Set/array (deny-set) query results: resolved — supported in v1 as empty-means-allow / non-empty-means-deny (R10). +- Undefined query result: resolved — clean Deny independent of `on_error` (R12), not a degenerate outcome. +- Global-vs-inline "package collision": resolved — not an error; same-package modules merge per Rego semantics (R6 reframed). + +### Deferred to Implementation + +- Exact inline-module cache key (raw source string vs. normalized) and whether the cached unit is a prepared `Engine` clone or the module text re-added on each base clone — settle when wiring the cache against the real API. +- Exact `regorus::Value` → diagnostics rendering for unrecognized decision-object fields and for set/array elements (likely `to_json_str`), confirmed against the real `Value` API during implementation. +- Whether a set/array result is distinguished from an object result via `Value::Set`/`Value::Array` vs `Value::Object` matching — confirm regorus's `Value` variants for a `deny[msg]` set at eval time. +- Whether input is built as a `regorus::Value` directly or via a JSON string + `set_input_json` — pick the path that best surfaces construction correctness. + +--- + +## Output Structure + + builtins/pdps/opa/ + ├── Cargo.toml + ├── src/ + │ ├── lib.rs # crate docs + module re-exports + │ ├── factory.rs # OpaPdpFactory (kind = "opa") + │ ├── resolver.rs # OpaResolver: base engine, clone-per-request, decision contract, on_error + │ ├── input.rs # AttributeBag -> regorus input (ported from cel activation.rs) + │ ├── decision.rs # Value (bool | object) -> PdpDecision; rich-object -> violation mapping + │ └── error.rs # BuildError + └── tests/ + └── visitor_opa_config.rs # e2e: YAML -> visitor -> factory -> resolver -> allow/deny + +--- + +## Implementation Units + +**Cross-cutting constraint (all units):** Requirement and plan-document identifiers (R#, U#, AE#, F#) and references to these planning documents must NOT appear in commit messages, source code, rustdoc comments, or test names. Code and commits stand on their own — describe the behavior, not the plan artifact. These IDs are traceability aids for planning and review only. (The `Requirements`/`Covers AE#` annotations in the units below live in this plan; they do not travel into the diff.) + +- U1. **Scaffold the crate and wire it into the workspace** + +**Goal:** Create `cpex-pdp-opa` as an empty-but-compiling crate registered in the workspace and `cpex-builtins`, so later units build against real wiring. + +**Requirements:** R1, R2, R3 + +**Dependencies:** None + +**Files:** +- Create: `builtins/pdps/opa/Cargo.toml` +- Create: `builtins/pdps/opa/src/lib.rs` (module stubs + re-exports) +- Modify: `Cargo.toml` (root) — add `builtins/pdps/opa` to `[workspace] members` and `default-members`, and add `cpex-pdp-opa = { path = "builtins/pdps/opa", version = "0.2.2" }` to `[workspace.dependencies]` +- Modify: `crates/cpex-builtins/Cargo.toml` — add `opa = ["dep:cpex-pdp-opa"]` feature, add `"opa"` to `default`, add `cpex-pdp-opa = { workspace = true, optional = true }` +- Modify: `crates/cpex-builtins/src/lib.rs` — `#[cfg(feature = "opa")] pub use cpex_pdp_opa::OpaPdpFactory;`, push in `builtin_pdps()`, add `+ cfg!(feature = "opa") as usize` to the factory-count test + +**Approach:** +- Mirror `builtins/pdps/cel/Cargo.toml`: inherit workspace package fields, `apl-core`/`async-trait`/`serde_yaml`/`thiserror`/`tracing` as workspace deps, `apl-cmf`/`apl-cpex`/`cpex-core`/`tokio` as dev-deps, `[lints] workspace = true`. +- Pin `regorus = { version = "0.11", default-features = false, features = ["arc", ...] }`. `default-features = false` is deliberate: regorus's `default` set (`full-opa`) enables `http.send`, `net`, `opa.runtime`, `jsonschema`, and the `mimalloc` C allocator. Enable `arc` (load-bearing — without it the base `Engine` is not `Send`/`Sync`) plus only the minimal builtin features the PDP needs, and **exclude** `http`, `net`, and `opa-runtime` so inline policy authors (the less-trusted A2 actor) cannot reach network egress or environment introspection. Enumerate the exact feature list against regorus 0.11's feature table during implementation, mirroring how `builtins/pdps/cel/Cargo.toml` pins CEL features explicitly with a comment. Add `serde_json` if the input path uses JSON. +- Do NOT edit apl-core: `PdpDialect::Opa` and `from_key("opa")` already exist. + +**Patterns to follow:** +- `builtins/pdps/cel/Cargo.toml`, `builtins/pdps/cedar-direct/Cargo.toml`, root `Cargo.toml` members/default-members/dependencies blocks, `crates/cpex-builtins/src/lib.rs:37-40, 98-105, 149-157`. + +**Test scenarios:** +- Integration: `cargo build -p cpex-pdp-opa` and `cargo build -p cpex-builtins --features opa` compile. +- Covers AE (wiring precondition for all): the `cpex-builtins` factory-count test passes with `opa` enabled, asserting `builtin_pdps()` includes the opa factory. + +**Verification:** +- Workspace builds with the new crate; `cpex-builtins` default features include `opa`; factory-count test green. + +--- + +- U2. **AttributeBag → Rego input mapping** + +**Goal:** Translate the flat dotted `AttributeBag` into a nested regorus `input` document. + +**Requirements:** R7 + +**Dependencies:** U1 + +**Files:** +- Create: `builtins/pdps/opa/src/input.rs` +- Modify: `builtins/pdps/opa/src/lib.rs` (expose module) + +**Approach:** +- Port CEL's `activation.rs` tree-builder: split dotted keys into nested maps; namespace-wins-on-leaf-collision with a `tracing::warn!`. +- Map `AttributeValue`: `Bool`/`Int`/`Float`/`String` to scalars; `StringSet` to a sorted array (stable indexing); whole-number `Float` coerced to integer, matching CEL's `float_to_value`. +- Emit a regorus input object (build `regorus::Value` directly, or a `serde_json::Value`/JSON string fed to `set_input_json` — see deferred question). + +**Patterns to follow:** +- `builtins/pdps/cel/src/activation.rs` (tree build, collision rule, type coercions). + +**Test scenarios:** +- Happy path: dotted keys (`subject.id`, `subject.type`) become nested fields readable as `input.subject.id`. +- Happy path: `Bool`/`Int`/`Float`/`String` scalars round-trip to the right regorus value. +- Edge case: `StringSet` becomes a sorted array; `input.session.labels[0]` is deterministic. +- Edge case: whole-number float (`2.0`) maps to an integer so `input.delegation.depth == 2` holds. +- Edge case: leaf/namespace collision (`delegation` scalar + `delegation.depth`) resolves namespace-wins. +- Edge case: empty bag produces an empty (but valid) input object. + +**Verification:** +- Unit tests build an input from a bag and assert a small Rego snippet reads the expected nested fields. + +--- + +- U3. **Config parsing and base-engine preparation (`from_config`)** + +**Goal:** Parse the `global.pdp` block and build a base `Engine` loaded with global modules and external `data`, failing loudly at load on bad config or unparseable Rego. + +**Requirements:** R4, R6, R8, R10, R12 + +**Dependencies:** U1 + +**Files:** +- Create: `builtins/pdps/opa/src/error.rs` (`BuildError`) +- Create: `builtins/pdps/opa/src/resolver.rs` (`OpaResolver`, `OnError`, `from_config`, base-engine field) +- Modify: `builtins/pdps/opa/src/lib.rs` + +**Approach:** +- `BuildError` mirrors cedar's: `ConfigShape(String)`, `ModuleFile { path, source }`, `ModuleParse(String)`, `DataFile { path, source }`, `DataParse(String)`. +- `from_config`: require a mapping; reject unknown keys via a `KNOWN_KEYS` loop (`["kind", "modules", "module_files", "data", "data_files", "decision_field", "on_error"]`); parse `on_error` (`deny`|`allow`, default deny) exactly as CEL; read `decision_field` (default `allow`). +- Modules: accept `modules` (list of inline text) and/or `module_files` (list of paths, read via `std::fs::read_to_string` → `ModuleFile`). Add each to the base engine via `add_policy`; a parse failure here is a load-time `ModuleParse` error (fail-fast startup). +- Data: accept `data` (inline mapping serialized to JSON) and/or `data_files`; load via `add_data_json`/`add_data`; a merge conflict is a load error. +- Store the prepared base `Engine`, `on_error`, and `decision_field` on the resolver, plus an empty inline-module cache field whose get-or-build semantics are defined in U4. + +**Patterns to follow:** +- `builtins/pdps/cedar-direct/src/resolver.rs:118-172` (text-vs-file tuple, file-read error mapping), `builtins/pdps/cel/src/resolver.rs:215-249` (KNOWN_KEYS + on_error), `builtins/pdps/cedar-direct/src/error.rs`. + +**Test scenarios:** +- Happy path: config with one inline module + one data block builds a resolver. +- Covers AE6: external `data` (`roles.alice = ["reader"]`) loads and is readable by a policy. +- Covers AE5: a module with a Rego syntax error yields a `BuildError::ModuleParse` at `from_config`. +- Error path: unknown key (`on_errr`) rejected with a message naming the key. +- Error path: `on_error: maybe` rejected; `on_error: allow` parses to `OnError::Allow`. +- Error path: `module_files` path that doesn't exist → `ModuleFile` error naming the path. +- Edge case: `decision_field` override is read and stored. + +**Verification:** +- Unit tests cover build success, each config-shape rejection, and load-time parse failure. + +--- + +- U4. **Decision contract: evaluate query and map result (incl. rich objects)** + +**Goal:** Implement `PdpResolver::evaluate` — clone the base (plus any inline module), set input, `eval_rule` the query, and map `Value` (boolean, object, set/array, or undefined) to a `PdpDecision`, honoring `on_error`. + +**Requirements:** R5, R6, R9, R10, R11, R12 + +**Dependencies:** U2, U3 + +**Files:** +- Create: `builtins/pdps/opa/src/decision.rs` (Value → PdpDecision, rich-object mapping) +- Modify: `builtins/pdps/opa/src/resolver.rs` (`evaluate`, inline-module cache, `dialect()`) +- Modify: `builtins/pdps/opa/src/lib.rs` + +**Approach:** +- Read `query` (required) and optional `module` from `call.args`; a missing `query` is a `PdpError::Dispatch` (author bug), mirroring CEL's missing-`expr`. +- Inline module path: get-or-build a prepared engine (base clone + `add_policy(inline)`) from a bounded cache keyed by module source (cap+reject+log, never evict). `add_policy` failure here = compile error = always deny. A same-package inline module merges with the global module per Rego semantics — this is expected, not an error. +- No inline module: clone the base directly. +- Set input (from U2), `eval_rule(query)`: + - `Value::Bool(true)` → Allow; `Value::Bool(false)` → Deny (`reason` "query evaluated to false", `rule_source` "opa"). + - `Value::Object` → read `decision_field` (default `allow`) as bool. `true` → Allow. `false`/deny → build Deny and enrich: `reason`/`message` string → Deny `reason`; `violations`/`errors` array → diagnostics; policy id field → `rule_source` (else "opa"); remaining fields serialized (e.g. `to_json_str`) into diagnostics. + - `Value::Set`/`Value::Array` (deny-set idiom) → empty → Allow; non-empty → Deny with the elements as violations in diagnostics (`rule_source` "opa"). + - `Value::Undefined` → clean Deny (`reason` "query undefined — not granted", `rule_source` "opa"), independent of `on_error`. + - missing/non-bool decision field on an object, or any other non-bool/non-object/non-set value → degenerate → `on_error`. + - `Err(...)` from eval → runtime error → `on_error`. +- `on_error` decision + compile-error decision helpers mirror CEL (`error!` logging on allow-through and on compile errors). + +**Technical design:** *(directional guidance, not implementation spec)* + + eval_rule(query) -> + Bool(true) => Allow + Bool(false) => Deny{reason, rule_source:"opa"} + Object(o) => match o[decision_field].as_bool() + Some(true) => Allow + Some(false) => Deny + enrich(o) // reason/message, violations, id, rest + None => on_error(degenerate) + Set/Array empty => Allow + Set/Array non-empty => Deny{violations: elements, rule_source:"opa"} + Undefined => Deny{reason:"undefined — not granted"} // NOT on_error + other (non-bool/object/set) => on_error(degenerate) + Err(e) => on_error(runtime) + // add_policy(inline) failure => compile error => always Deny + // same-package global+inline modules merge (Rego semantics) — not an error + +**Patterns to follow:** +- `builtins/pdps/cel/src/resolver.rs` (`evaluate`, `on_error_decision`, `compile_error_decision`, compile cache cap+reject+log), `builtins/pdps/cedar-direct/src/decision.rs` (diagnostics/rule_source/reason contract). + +**Test scenarios:** +- Covers AE1: boolean-`true` policy → Allow, pipeline continues. +- Happy path: boolean-`false` policy → Deny with `rule_source` "opa". +- Covers AE3: object `{ allow:false, reason:"...", violations:[...] }` → Deny; reason carried, violations in diagnostics. +- Happy path: object with a policy id field → `rule_source` is that id. +- Edge case: object `{ allow:true }` → Allow. +- Happy path (deny-set idiom): a `deny[msg]` policy with an empty deny set → Allow; with a non-empty deny set → Deny with the messages as violations in diagnostics. +- Covers AE4: object with a missing/non-bool decision field, or any other non-bool/object/set value, under default `on_error` → Deny (degenerate). +- Covers AE2: eval runtime error under `on_error: deny` → Deny; under `on_error: allow` → Allow. +- Edge case: `Value::Undefined` (no matching rule, no `default`) → clean Deny even under `on_error: allow` (undefined does not fail open). +- Error path: inline module with a syntax error → always Deny even with `on_error: allow`. +- Error path: missing `query` in step args → `PdpError::Dispatch`. +- Edge case: inline-module cache cap reached → new module rejected → routed through `on_error`; cached module still evaluates. +- Integration: a same-package inline module merges with a global module (Rego semantics) and can reference a global-module/`data` value — no error on package reuse. +- Concurrency: many threads sharing one `Arc` evaluate the same query and get correct per-request decisions (exercises clone-per-request under the `arc` feature). + +**Verification:** +- Unit tests cover every branch of the decision map, on_error both ways, compile-vs-runtime asymmetry, cache cap, and concurrent evaluation. + +--- + +- U5. **End-to-end visitor wiring test** + +**Goal:** Prove an operator dropping a `kind: opa` block plus an `opa:` route step into YAML gets a real allow/deny decision through the apl-cpex visitor, with no Rust glue. + +**Requirements:** R1, R3, R9 (and exercises AE1, AE2) + +**Dependencies:** U4 + +**Files:** +- Create: `builtins/pdps/opa/tests/visitor_opa_config.rs` + +**Approach:** +- Mirror `builtins/pdps/cel/tests/visitor_cel_config.rs`: `build_manager_with_yaml` wiring `pdp_factories: vec![Arc::new(OpaPdpFactory::new())]` in the full `AplOptions` field set; `load_config_yaml`; `initialize`; construct a request with `SecurityExtension`/`MetaExtension`; invoke `cmf.tool_pre_invoke`; assert on `continue_processing` and `violation`. +- YAML declares `global.apl.pdp: [{ kind: opa, modules: [ ... ] }]` and a route `opa: { query: "data.authz.allow" }`. +- Include a negative config test: malformed `on_error` (or unknown key) rejected at `load_config_yaml`. + +**Patterns to follow:** +- `builtins/pdps/cel/tests/visitor_cel_config.rs` end to end. + +**Test scenarios:** +- Covers AE1: allow — subject the policy permits → `continue_processing` true. +- Covers AE2 / R12: a route whose query errors or denies → `continue_processing` false, `violation` present. +- Error path: malformed config rejected at load with a message naming the offending field. + +**Verification:** +- `cargo test -p cpex-pdp-opa` green; the resolver is built by the visitor from YAML, never constructed directly in the test. + +--- + +## System-Wide Impact + +- **Interaction graph:** New PDP dialect on the existing `PdpRouter` dispatch path; no change to routing logic — `PdpDialect::Opa` already routes. Only new registration in `cpex-builtins`. +- **Error propagation:** Config/parse errors surface at `load_config_yaml` (via `BuildError` → visitor → `PluginError::Config`); runtime/degenerate outcomes become `PdpDecision` Deny/Allow, never panics. +- **State lifecycle risks:** Per-request engine clones are isolated; no shared mutable state. The inline-module cache grows bounded (cap+reject+log, never evict). +- **API surface parity:** Mirrors cel/cedar's `PdpResolver`/`PdpFactory` contract exactly; `on_error` semantics identical to cel. +- **Integration coverage:** The e2e visitor test (U5) proves the YAML→decision path that unit tests can't. +- **Unchanged invariants:** apl-core is untouched (`PdpDialect::Opa` pre-exists); cel/cedar crates and tests are unchanged; `deny.toml` unchanged (regorus is Apache-2.0 on the allow-list, crates.io-sourced). + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| regorus 0.11 API differs from researched signatures (owned `String` args, `enable_tracing` arity, `arc` feature name) | U1 pins the version and enables `arc`; adapt call sites against docs.rs 0.11 during implementation; deferred questions flag the uncertain spots. | +| Clone-per-request cost under load | Clones are Rc/Arc-backed and cheap by design; `compile_with_entrypoint` is a documented fallback if profiling demands it (deferred). | +| A transitive regorus dep carries a non-allow-listed license | Run `cargo deny check` (make audit) in U1; if a transitive license is off-list, surface it before merge — do not silently edit `deny.toml`. | +| `add_policy` on a per-request clone re-parses inline modules (perf) | Inline-module cache holds prepared engines so parsing happens once per distinct source, not per request. | + +--- + +## Sources & References + +- **Origin document:** [docs/brainstorms/opa-rego-pdp-builtin-requirements.md](docs/brainstorms/opa-rego-pdp-builtin-requirements.md) +- Related code: `builtins/pdps/cel/`, `builtins/pdps/cedar-direct/`, `crates/cpex-builtins/src/lib.rs`, `crates/apl-core/src/step.rs`, `crates/apl-core/src/evaluator.rs`, `crates/apl-core/src/attributes.rs` +- Related issue: contextforge-org/cpex#137 +- External docs: regorus 0.11.0 — https://docs.rs/regorus/0.11.0/regorus/struct.Engine.html , https://crates.io/crates/regorus From 6a8e66931211e31f80e15817d534ffe5652e9bb9 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 29 Jul 2026 08:26:27 -0400 Subject: [PATCH 07/10] fix(pdp-opa): fail closed on evaluation task panic A spawn_blocking JoinError means the eval task panicked or aborted, which is an abnormal termination rather than a policy outcome. Route it through the always-deny path instead of on_error so a panic cannot fail open under on_error: allow. --- builtins/pdps/opa/src/resolver.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs index abcefc0d..b1c9ff7a 100644 --- a/builtins/pdps/opa/src/resolver.rs +++ b/builtins/pdps/opa/src/resolver.rs @@ -439,14 +439,16 @@ impl PdpResolver for OpaResolver { }) .await; - // 4. Apply on_error to degenerate outcomes; a task panic fails closed - // through on_error too. + // 4. Apply on_error to genuine degenerate outcomes (eval error, + // non-decision value). A task panic/abort is an abnormal + // termination, not a policy outcome, so it always denies regardless + // of on_error — the same fail-closed treatment as compile errors. match outcome { Ok(EvalOutcome::Decision(decision)) => Ok(decision), Ok(EvalOutcome::OnError(cause)) => Ok(self.on_error_decision(cause)), - Err(join_err) => { - Ok(self.on_error_decision(format!("OPA evaluation task failed: {join_err}"))) - }, + Err(join_err) => Ok( + self.compile_error_decision(format!("OPA evaluation task panicked: {join_err}")) + ), } } } From f3ce51b2a9d6a337e5210cf516723453dc496cfc Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 30 Jul 2026 21:05:02 -0400 Subject: [PATCH 08/10] docs(pdp-opa): correct cache feature docs, pin input isolation The regorus `cache` feature caches compiled regex and glob patterns in a process-global LRU keyed by pattern text, not rule-evaluation results. The previous comment claimed the latter, which reads as a possible cross-input decision leak. Record where input isolation actually comes from: `Interpreter::clone` resets the memo maps and `Engine::eval_rule` clears them before every evaluation. Add a deterministic sequential-reuse test so a regorus bump that starts retaining input-derived state fails a test. Also document why the cold-cache double-prepare in `engine_for` is intentional, and drop the unused `coverage` feature. Signed-off-by: Frederico Araujo --- builtins/pdps/opa/Cargo.toml | 9 ++++--- builtins/pdps/opa/src/decision.rs | 1 + builtins/pdps/opa/src/resolver.rs | 44 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/builtins/pdps/opa/Cargo.toml b/builtins/pdps/opa/Cargo.toml index 6e9dda65..f5f13167 100644 --- a/builtins/pdps/opa/Cargo.toml +++ b/builtins/pdps/opa/Cargo.toml @@ -58,11 +58,12 @@ regorus = { version = "0.11", default-features = false, features = [ "urlquery", "uuid", "yaml", - # `cache` memoizes rule evaluation (a per-request eval speedup); `coverage` - # is a cheap, dependency-free flag kept for parity with regorus's pure-builtin - # set. Neither reaches network or runtime-introspection surfaces. + # `cache` bounds and reuses compiled regex and glob patterns in a + # process-global LRU keyed by pattern text (regorus defaults: 256 regex, 128 + # glob). It caches compiled patterns only, never evaluation results, so it + # holds no input-derived state and is safe to share across requests. Without + # it, every regex and glob in a policy recompiles on every evaluation. "cache", - "coverage", ] } serde_json = { workspace = true } async-trait = { workspace = true } diff --git a/builtins/pdps/opa/src/decision.rs b/builtins/pdps/opa/src/decision.rs index e4e95a9d..fdefaef7 100644 --- a/builtins/pdps/opa/src/decision.rs +++ b/builtins/pdps/opa/src/decision.rs @@ -73,6 +73,7 @@ pub(crate) fn map_query_result(value: &Value, decision_field: &str) -> Mapped { fn map_object(value: &Value, decision_field: &str) -> Mapped { let obj = match value.as_object() { Ok(o) => o, + // Unreachable given the caller's `Value::Object` match; defensive. Err(_) => return Mapped::Degenerate("OPA query object was not an object".to_string()), }; diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs index b1c9ff7a..61c2acc0 100644 --- a/builtins/pdps/opa/src/resolver.rs +++ b/builtins/pdps/opa/src/resolver.rs @@ -16,6 +16,13 @@ // re-parse. (All regorus `set_input`/`eval_*` methods take `&mut self`, so a // per-request clone is what makes concurrent evaluation possible at all.) // +// Clone-per-request is input-isolated by regorus contract, not by luck. +// `Interpreter::clone` resets the rule-value and builtin memo maps to empty, and +// `Engine::eval_rule` calls `clean_internal_evaluation_state()` before every +// evaluation, clearing those maps plus `data` and the scope stack. A regorus +// change to either would break input isolation, so `concurrent_evaluation_is_correct` +// and `sequential_reuse_does_not_leak_prior_input` pin the observable behavior. +// // Inline `opa: { module: "..." }` steps get their own bounded cache of // prepared engines (base + that module), so a distinct inline module is parsed // at most once. The cache follows the workspace "cap + reject + log, never @@ -276,6 +283,15 @@ impl OpaResolver { // Prepare base + inline module. A parse failure here is a compile error // (always denies), not a runtime condition. + // + // A cold-start race can have two threads prepare the same module at + // once. The second insert overwrites an equivalent engine, so the only + // cost is a duplicated compile on first hit. That is deliberate: + // holding the write lock across `add_policy` would serialize all + // inline-module preparation. The cap check below is race-safe because + // the length check and the insert share one write-lock acquisition, and + // the `contains_key` guard keeps a same-key racer from being rejected + // once the cache is full. let mut engine = self.base_engine.clone(); let package = engine .add_policy(INLINE_MODULE_NAME.to_string(), src.to_string()) @@ -945,9 +961,35 @@ msg := "not a decision" ); } + /// Reusing one resolver across requests must not serve a prior request's + /// decision. Pins the regorus invariant the clone-per-request model rests + /// on: cloning an engine and evaluating both clear the interpreter's + /// input-derived memo state. + #[tokio::test] + async fn sequential_reuse_does_not_leak_prior_input() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); + for (subject, expected_allow) in [("alice", true), ("eve", false), ("alice", true)] { + let out = r + .evaluate(&call("data.authz.allow", None), &bag(subject)) + .await + .unwrap(); + if expected_allow { + assert_eq!(out.decision, Decision::Allow, "{subject} must be allowed"); + } else { + assert!( + matches!(out.decision, Decision::Deny { .. }), + "{subject} must be denied, got {:?}", + out.decision, + ); + } + } + } + /// Many threads sharing one `Arc` evaluate concurrently and /// each gets the correct per-request decision (exercises clone-per-request - /// under the `arc` feature). + /// under the `arc` feature). Together with + /// `sequential_reuse_does_not_leak_prior_input`, guards against a regorus + /// change that starts retaining input-derived state across evaluations. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_evaluation_is_correct() { let r = Arc::new(resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny)); From c3e47efeef751333fb6fbf834a799f53a813ba44 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 30 Jul 2026 21:07:30 -0400 Subject: [PATCH 09/10] feat(pdp-opa): make max_cache_entries configurable The cache-full warning told operators to raise `with_max_cache_entries`, but the factory only builds from config and unknown keys are rejected, so an operator hitting the cap through unified config had no lever at all. Accept `max_cache_entries` as a config key with the same strictness as the other fields: a present non-integer or negative value is a load error, not a silent default. A cap of 0 refuses inline modules while global-module steps keep evaluating. Signed-off-by: Frederico Araujo --- builtins/pdps/opa/src/factory.rs | 1 + builtins/pdps/opa/src/resolver.rs | 118 +++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/builtins/pdps/opa/src/factory.rs b/builtins/pdps/opa/src/factory.rs index 2f0c7a32..a4c19b14 100644 --- a/builtins/pdps/opa/src/factory.rs +++ b/builtins/pdps/opa/src/factory.rs @@ -12,6 +12,7 @@ // pdp: // - kind: opa // on_error: deny # optional; deny | allow, default deny +// max_cache_entries: 1024 # optional; cap on distinct inline modules // modules: # global Rego modules (inline and/or files) // - | // package authz diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs index 61c2acc0..a4ed5ee4 100644 --- a/builtins/pdps/opa/src/resolver.rs +++ b/builtins/pdps/opa/src/resolver.rs @@ -136,6 +136,10 @@ impl OpaResolver { /// alice: [reader] /// data_files: # optional; paths to JSON/YAML data files /// - data/roles.json + /// max_cache_entries: 1024 # optional; cap on distinct inline modules, + /// # default 1024. 0 disables inline modules (a + /// # step carrying one denies); global-module + /// # steps are unaffected. /// ``` /// /// Global modules and data are parsed/loaded here, once. A Rego parse error @@ -155,6 +159,7 @@ impl OpaResolver { "module_files", "data", "data_files", + "max_cache_entries", ]; for (key, _) in map { let Some(name) = key.as_str() else { @@ -243,7 +248,8 @@ impl OpaResolver { base_engine: engine, global_packages, inline_cache: RwLock::new(HashMap::new()), - max_cache_entries: DEFAULT_MAX_CACHE_ENTRIES, + max_cache_entries: read_usize(map, "max_cache_entries")? + .unwrap_or(DEFAULT_MAX_CACHE_ENTRIES), }) } @@ -255,7 +261,8 @@ impl OpaResolver { } /// Override the inline-module cache cap (default - /// [`DEFAULT_MAX_CACHE_ENTRIES`]). + /// [`DEFAULT_MAX_CACHE_ENTRIES`]). Equivalent to the `max_cache_entries` + /// config key, for hosts constructing a resolver in Rust. pub fn with_max_cache_entries(mut self, max_cache_entries: usize) -> Self { self.max_cache_entries = max_cache_entries; self @@ -311,8 +318,8 @@ impl OpaResolver { tracing::warn!( cap = self.max_cache_entries, "OPA inline-module cache full; rejecting new module. Existing entries are not \ - evicted. Increase `with_max_cache_entries` if the policy legitimately exceeds \ - the default bound." + evicted. Raise `max_cache_entries` in the OPA PDP config block if the policy \ + legitimately needs more distinct inline modules." ); return Err(EngineError::CacheFull { cap: self.max_cache_entries, @@ -502,6 +509,23 @@ fn read_string(map: &serde_yaml::Mapping, key: &str) -> Result, B } } +/// Read an optional non-negative integer field. A key that is absent (or +/// explicitly null) yields `None`; a present value that is not a non-negative +/// integer is a config error rather than a silent default, matching the +/// strictness applied to the string fields. +fn read_usize(map: &serde_yaml::Mapping, key: &str) -> Result, BuildError> { + match map.get(serde_yaml::Value::String(key.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(value) => value + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .map(Some) + .ok_or_else(|| { + BuildError::ConfigShape(format!("`{key}` must be a non-negative integer")) + }), + } +} + /// Read an optional sequence-of-strings field. A missing key yields an empty /// vec; a present-but-non-sequence value, or a non-string element, is a config /// error. @@ -570,6 +594,30 @@ data: assert_eq!(r.decision_field, "permit"); } + #[test] + fn max_cache_entries_read_from_config() { + let r = cfg("kind: opa\nmax_cache_entries: 7\n").unwrap(); + assert_eq!(r.max_cache_entries, 7); + } + + #[test] + fn max_cache_entries_defaults_when_absent() { + let r = cfg("kind: opa\n").unwrap(); + assert_eq!(r.max_cache_entries, DEFAULT_MAX_CACHE_ENTRIES); + } + + #[test] + fn non_integer_max_cache_entries_is_rejected() { + let err = cfg("kind: opa\nmax_cache_entries: many\n").unwrap_err(); + assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("max_cache_entries"))); + } + + #[test] + fn negative_max_cache_entries_is_rejected() { + let err = cfg("kind: opa\nmax_cache_entries: -1\n").unwrap_err(); + assert!(matches!(err, BuildError::ConfigShape(m) if m.contains("max_cache_entries"))); + } + #[test] fn unknown_key_rejected_naming_the_key() { let err = cfg("kind: opa\non_errr: allow\n").unwrap_err(); @@ -712,6 +760,13 @@ mod eval_tests { serde_yaml::Value::String(s.to_string()) } + /// Build a resolver from raw config YAML, for tests exercising config keys + /// the `resolver` helper does not set. + fn resolver_from_yaml(yaml: &str) -> OpaResolver { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + OpaResolver::from_config(&value).unwrap() + } + /// Build an `opa:` step call with a query and optional inline module. fn call(query: &str, module: Option<&str>) -> PdpCall { let mut m = serde_yaml::Mapping::new(); @@ -940,6 +995,61 @@ msg := "not a decision" assert_eq!(again.decision, Decision::Allow); } + /// The `max_cache_entries` config key must reach the resolver, not just the + /// Rust builder: an operator hitting the cap through unified config has no + /// other lever. + #[tokio::test] + async fn config_max_cache_entries_reaches_the_resolver() { + let r = resolver_from_yaml("kind: opa\nmax_cache_entries: 1\n"); + let m1 = "package a\nallow if input.subject.id == \"alice\"\n"; + let m2 = "package b\nallow if input.subject.id == \"alice\"\n"; + + let first = r + .evaluate(&call("data.a.allow", Some(m1)), &bag("alice")) + .await + .unwrap(); + assert_eq!(first.decision, Decision::Allow); + + let second = r + .evaluate(&call("data.b.allow", Some(m2)), &bag("alice")) + .await + .unwrap(); + assert!(matches!(second.decision, Decision::Deny { .. })); + assert!(second.diagnostics.iter().any(|d| d.contains("cache full"))); + } + + /// A cap of 0 is a lockdown stance, not a footgun: inline modules are + /// refused (and deny) while global-module steps keep evaluating, because a + /// step without an inline module never touches the cache. + #[tokio::test] + async fn zero_max_cache_entries_disables_inline_modules_only() { + const YAML: &str = r#"kind: opa +max_cache_entries: 0 +modules: + - | + package authz + default allow := false + allow if input.subject.id == "alice" +"#; + let r = resolver_from_yaml(YAML); + + let global = r + .evaluate(&call("data.authz.allow", None), &bag("alice")) + .await + .unwrap(); + assert_eq!(global.decision, Decision::Allow); + + let inline = "package extra\nallow := true\n"; + let out = r + .evaluate(&call("data.extra.allow", Some(inline)), &bag("alice")) + .await + .unwrap(); + assert!( + matches!(out.decision, Decision::Deny { .. }), + "an inline module must be refused at a cap of 0", + ); + } + /// A cache-full rejection is a resource limit, not a policy outcome, so it /// denies even under on_error: allow — it must not fail open. #[tokio::test] From f36c220811e36a4c113994e1eef29ff0a8aeb3b7 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 30 Jul 2026 21:13:41 -0400 Subject: [PATCH 10/10] fix(pdp-opa): bound the diagnostics a deny may carry A decision object's violations and errors lists, a deny set's elements, and the whole-object serialization were all unbounded. Their content is policy-authored and can be derived from arbitrarily large `data`, so a single deny could produce an unbounded diagnostic payload once an audit sink reads it. Cap element count and line length on all three paths. Truncation is always marked so a bounded diagnostic never reads as a complete one, and a deny set's reason keeps reporting the true violation count even when its diagnostics are capped. Signed-off-by: Frederico Araujo --- builtins/pdps/opa/src/decision.rs | 143 ++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 9 deletions(-) diff --git a/builtins/pdps/opa/src/decision.rs b/builtins/pdps/opa/src/decision.rs index fdefaef7..7f7283bb 100644 --- a/builtins/pdps/opa/src/decision.rs +++ b/builtins/pdps/opa/src/decision.rs @@ -24,7 +24,10 @@ // // A Deny carries a human-readable `reason`, a `rule_source` (a policy-supplied // id when present, else `"opa"`), and diagnostics detailing the cause so an -// auditor can see why without re-running the policy. +// auditor can see why without re-running the policy. Diagnostics are bounded in +// both element count and line length, since their content is policy-authored +// and can be derived from arbitrarily large `data`. Truncation is always marked, +// so a bounded diagnostic never reads as a complete one. use regorus::Value; @@ -34,6 +37,14 @@ use apl_core::step::PdpDecision; /// The fallback attribution when a policy does not name a rule id. const DEFAULT_RULE_SOURCE: &str = "opa"; +/// Bounds on the diagnostics one deny may carry. A decision object and a deny +/// set are policy-authored and can derive their elements from a large `data` +/// table, so both the number of lines and the length of each line are capped +/// before an audit sink sees them. Worst case for a single deny is roughly +/// 35 KiB: two capped element lists plus the whole-object line. +const MAX_DIAGNOSTIC_ELEMENTS: usize = 16; +const MAX_DIAGNOSTIC_LEN: usize = 1024; + /// Outcome of mapping a query result. A `Decision` is terminal (allow/deny); /// `Degenerate` means the value carries no decision and the caller applies /// `on_error`. @@ -95,12 +106,12 @@ fn map_object(value: &Value, decision_field: &str) -> Mapped { // Recognized violation lists become individual diagnostics. for key in ["violations", "errors"] { if let Some(list) = obj.get(&Value::from(key)).and_then(|v| v.as_array().ok()) { - diagnostics.extend(list.iter().map(render)); + diagnostics.extend(bounded_elements(list.iter())); } } // Serialize the whole object so nothing the author returned is lost // to the audit trail, even fields we did not specifically read. - diagnostics.push(format!("opa: {}", render(value))); + diagnostics.push(bounded(format!("opa: {}", render(value)))); Mapped::Decision(deny(reason, rule_source, diagnostics)) }, @@ -114,13 +125,19 @@ fn map_object(value: &Value, decision_field: &str) -> Mapped { /// Set/array (deny-set / violation-set idiom): empty → allow, non-empty → deny /// with the elements as violations. fn map_collection<'a>(items: impl ExactSizeIterator) -> Mapped { - let violations: Vec = items.map(render).collect(); - if violations.is_empty() { - Mapped::Decision(allow()) - } else { - let reason = format!("OPA policy produced {} violation(s)", violations.len()); - Mapped::Decision(deny(reason, DEFAULT_RULE_SOURCE.to_string(), violations)) + // Count before consuming: the reason reports the true violation count even + // when the diagnostics below are capped, so an auditor is never told "3 + // violations" when the policy produced 300. + let total = items.len(); + if total == 0 { + return Mapped::Decision(allow()); } + let reason = format!("OPA policy produced {total} violation(s)"); + Mapped::Decision(deny( + reason, + DEFAULT_RULE_SOURCE.to_string(), + bounded_elements(items), + )) } fn allow() -> PdpDecision { @@ -147,6 +164,30 @@ fn get_str(obj: ®orus::value::Object, key: &str) -> Option { .map(|s| s.to_string()) } +/// Cap one diagnostic line, cutting on a UTF-8 boundary and noting how much was +/// dropped so an auditor can tell a bounded line from a complete one. +fn bounded(s: String) -> String { + if s.len() <= MAX_DIAGNOSTIC_LEN { + return s; + } + let cut = s.floor_char_boundary(MAX_DIAGNOSTIC_LEN); + format!("{}... [{} more bytes omitted]", &s[..cut], s.len() - cut) +} + +/// Render at most [`MAX_DIAGNOSTIC_ELEMENTS`] elements, each capped, followed by +/// a count line when the list was longer. +fn bounded_elements<'a>(items: impl ExactSizeIterator) -> Vec { + let total = items.len(); + let mut out: Vec = items + .take(MAX_DIAGNOSTIC_ELEMENTS) + .map(|item| bounded(render(item))) + .collect(); + if total > out.len() { + out.push(format!("[{} more element(s) omitted]", total - out.len())); + } + out +} + /// Render a value for a diagnostic line: a bare string as-is, everything else /// as compact JSON (falling back to a placeholder if it cannot be rendered). fn render(value: &Value) -> String { @@ -335,6 +376,90 @@ mod tests { } } + fn diagnostics_of(m: Mapped) -> Vec { + match m { + Mapped::Decision(d) => d.diagnostics, + Mapped::Degenerate(c) => panic!("expected a decision, got degenerate: {c}"), + } + } + + #[test] + fn oversized_violation_list_is_capped() { + let violations: Vec = (0..100).map(|i| format!("\"v{i}\"")).collect(); + let m = map_query_result( + &val(&format!( + r#"{{"allow": false, "violations": [{}]}}"#, + violations.join(",") + )), + "allow", + ); + let diagnostics = diagnostics_of(m); + // Capped elements, one omitted-count line, one whole-object line. + assert_eq!(diagnostics.len(), MAX_DIAGNOSTIC_ELEMENTS + 2); + assert!( + diagnostics + .iter() + .any(|d| d == "[84 more element(s) omitted]"), + "truncation must be marked; got {diagnostics:?}" + ); + } + + #[test] + fn oversized_violation_element_is_truncated() { + let long = "x".repeat(10_000); + let m = map_query_result( + &val(&format!(r#"{{"allow": false, "violations": ["{long}"]}}"#)), + "allow", + ); + let diagnostics = diagnostics_of(m); + for line in &diagnostics { + assert!( + line.len() < MAX_DIAGNOSTIC_LEN + 64, + "line of {} bytes exceeds the bound", + line.len() + ); + } + assert!( + diagnostics.iter().all(|d| d.contains("more bytes omitted")), + "both the element and the whole-object line must be marked truncated; got \ + {diagnostics:?}" + ); + } + + /// The deny reason must report the real violation count even when the + /// diagnostics are capped, so an auditor is never told "16 violations" when + /// the policy produced 100. + #[test] + fn capped_deny_set_reason_reports_true_count() { + let elements: Vec = (0..100).map(|i| format!("\"v{i}\"")).collect(); + let m = map_query_result(&val(&format!("[{}]", elements.join(","))), "allow"); + match m { + Mapped::Decision(d) => { + match d.decision { + Decision::Deny { reason, .. } => { + assert_eq!( + reason.as_deref(), + Some("OPA policy produced 100 violation(s)") + ); + }, + other => panic!("expected Deny, got {other:?}"), + } + assert_eq!(d.diagnostics.len(), MAX_DIAGNOSTIC_ELEMENTS + 1); + }, + Mapped::Degenerate(c) => panic!("degenerate: {c}"), + } + } + + #[test] + fn truncation_cuts_on_utf8_boundary() { + // Three-byte characters do not divide MAX_DIAGNOSTIC_LEN evenly, so the + // cut lands mid-character unless the boundary is respected. + let long = "\u{4e16}".repeat(1_000); + let out = bounded(long); + assert!(out.contains("more bytes omitted")); + assert!(out.len() <= MAX_DIAGNOSTIC_LEN + 64); + } + /// The decision field is authoritative: `allow: true` allows even if the /// object also carries a populated `violations` list. Pins the documented /// precedence so a future change to the object path is a conscious choice.