diff --git a/Cargo.lock b/Cargo.lock index 9a49b7d6..9992d87f 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" @@ -740,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", @@ -838,6 +879,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 +1050,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 +1094,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 +1148,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 +1347,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 +1652,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 +2130,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 +2171,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 +2263,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 +2358,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 +2407,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 +2425,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 +2492,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 +2651,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 +2669,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 +2747,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 +3083,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 +3132,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 +3225,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 +3692,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -3503,7 +3704,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 +3756,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 +3806,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 +3858,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", ] @@ -4221,6 +4428,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/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 new file mode 100644 index 00000000..f5f13167 --- /dev/null +++ b/builtins/pdps/opa/Cargo.toml @@ -0,0 +1,93 @@ +# 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` 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", +] } +serde_json = { workspace = true } +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 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 } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/builtins/pdps/opa/src/decision.rs b/builtins/pdps/opa/src/decision.rs new file mode 100644 index 00000000..7f7283bb --- /dev/null +++ b/builtins/pdps/opa/src/decision.rs @@ -0,0 +1,476 @@ +// 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. 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; + +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"; + +/// 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`. +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, + // Unreachable given the caller's `Value::Object` match; defensive. + 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(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(bounded(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 { + // 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 { + 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()) +} + +/// 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 { + 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(_) + )); + } + + #[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:?}"), + } + } + + 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. + #[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/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..a4c19b14 --- /dev/null +++ b/builtins/pdps/opa/src/factory.rs @@ -0,0 +1,56 @@ +// 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 +// max_cache_entries: 1024 # optional; cap on distinct inline modules +// 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/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..d6473eec --- /dev/null +++ b/builtins/pdps/opa/src/lib.rs @@ -0,0 +1,222 @@ +// Location: ./builtins/pdps/opa/src/lib.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Fred Araujo +// +// cpex-pdp-opa — `PdpResolver` over Microsoft's pure-Rust `regorus` Rego +// interpreter. +// +// # 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 { + // 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) + ); + } +} diff --git a/builtins/pdps/opa/src/resolver.rs b/builtins/pdps/opa/src/resolver.rs new file mode 100644 index 00000000..a4ed5ee4 --- /dev/null +++ b/builtins/pdps/opa/src/resolver.rs @@ -0,0 +1,1153 @@ +// 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.) +// +// 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 +// 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, 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, HashSet}; +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, 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. + #[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, + /// 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. + 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 + /// 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 + /// 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", + "max_cache_entries", + ]; + 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(); + // 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"); + 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, + })?; + 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 + // 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() { + merge_data(&mut engine, "data", data)?; + } + } + 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(), + })?; + merge_data(&mut engine, &path, &parsed)?; + } + + Ok(Self { + dialect: PdpDialect::Opa, + on_error, + decision_field, + base_engine: engine, + global_packages, + inline_cache: RwLock::new(HashMap::new()), + max_cache_entries: read_usize(map, "max_cache_entries")? + .unwrap_or(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`]). 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 + } + + /// 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. + // + // 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()) + .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()); + 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. 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, + }); + } + cache.insert(src.to_string(), engine.clone()); + Ok(engine) + } + + /// 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 => { + 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], + }, + } + } + + /// 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 fail-closed condition — 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. 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. + Compile(String), + /// 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 { + 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. 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.compile_error_decision(format!( + "OPA inline-module cache full (cap={cap}); refusing a new module" + ))); + }, + }; + + // 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 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.compile_error_decision(format!("OPA evaluation task panicked: {join_err}")) + ), + } + } +} + +/// 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. 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 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. +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 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(); + 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"))); + } + + #[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)] +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 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(); + 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:?}"), + } + } + + /// 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_in_new_package_is_allowed() { + let r = resolver(&[ALLOW_WITH_DEFAULT], OnError::Deny); + let inline = "package extra\nallow if input.subject.id == \"alice\"\n"; + let out = r + .evaluate(&call("data.extra.allow", 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); + } + + /// 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] + 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", + ); + } + + /// 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). 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)); + 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 { .. })); + } + } + } + + #[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/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 + ); +} 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, 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/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: 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