Skip to content

feat(pdp-opa): embedded OPA/Rego policy decision point via regorus - #142

Merged
araujof merged 10 commits into
devfrom
feat/opa_rs
Jul 31, 2026
Merged

feat(pdp-opa): embedded OPA/Rego policy decision point via regorus#142
araujof merged 10 commits into
devfrom
feat/opa_rs

Conversation

@araujof

@araujof araujof commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Operators standardized on OPA/Rego previously had no in-process option in CPEX: their only choices were Cedar, CEL, or an external OPA server over HTTP. This adds cpex-pdp-opa, an embedded Rego policy decision point backed by the pure-Rust regorus interpreter, so existing Rego policy runs in-process with no sidecar and no network hop, alongside the cedar and cel builtins. It fills the PdpDialect::Opa dialect that already existed in apl-core, and ships enabled by default.

Closes #137.

Decision contract

A route's opa: { query: "data.authz.allow" } step evaluates the query against the request AttributeBag (mapped to the Rego input document). The query result maps to allow/deny:

Query result Outcome
true / false allow / deny
decision object allow/deny read from a configurable field (default allow); on deny, reason/message and violations/errors flow into the CPEX violation
set / array (the deny[msg] idiom) empty → allow, non-empty → deny with the elements as violations
undefined (no matching rule, no default) clean deny

This covers the dominant Rego authoring styles (boolean rules, decision objects, and deny-sets) so existing policy ports over without a rewrite.

Design decisions

  • Fail-closed by default. on_error: deny|allow (default deny) governs genuine eval errors and non-decision values. An undefined result is a clean deny independent of on_error (matching OPA's undefined-is-not-granted semantics, so on_error: allow cannot flip an ordinary non-match to allow). Rego parse errors, inline/global package collisions, and cache-full rejections always deny regardless of on_error — an author bug, a trust-boundary violation, and a resource limit must never fail open.
  • Base engine, cloned per request. regorus's set_input/eval_* all take &mut self, so there is no shared-immutable eval path. A base Engine holds the compiled global modules and data; because the arc feature Arc-shares that state, each request clones the base cheaply, sets its own input, and evaluates — no lock on the hot path. Eval runs on spawn_blocking so a CPU-heavy Rego evaluation cannot monopolize an async worker.
  • Security-scoped builtin surface. regorus is pinned default-features = false with an explicit allow-list that excludes http, net, opa-runtime, and jsonschema, keeping network egress and runtime introspection out of reach of policy authors. A regression test pins that http.send is unavailable.
  • Inline-module trust boundary. A route step may carry an inline module, but one whose Rego package collides with a global-module package is rejected fail-closed — inline modules may add new packages but cannot merge into and override operator policy.
  • apl-core-only at compile time. The crate depends only on apl-core (plus regorus and a tokio runtime for spawn_blocking); apl-cpex/cpex-core are dev-deps for the e2e test. Wiring is a one-feature-one-crate addition to cpex-builtins.

Notes

Deny diagnostics serialize the full policy-authored decision object with no size cap. It is latent (no consumer reads PdpDecision.diagnostics outside the PDP crates today) and worth bounding before an audit sink consumes it.

Tests

57 unit tests (bag→input mapping, config parsing and strictness, the full decision contract, on_error both ways, compile-vs-runtime asymmetry, inline-module isolation, cache cap, concurrency) plus 6 end-to-end tests driving config through the apl-cpex visitor (allow/deny, deny-set idiom, external data, load-time rejection, missing-query). cargo deny check passes with no license/source/ban changes.

araujof added 6 commits July 29, 2026 00:15
…ping

Add the cpex-pdp-opa crate backed by the pure-Rust regorus interpreter and
wire it into the workspace. regorus is pinned with default-features disabled
and an explicit builtin allow-list that excludes http, net, opa-runtime, and
jsonschema, keeping the embedded policy surface free of network egress and
runtime introspection.

Implements the AttributeBag -> Rego input mapping (flat dotted keys to a
nested JSON document, StringSet to sorted array, whole-number floats to
integers, namespace-wins on leaf/namespace collisions), plus a contract test
pinning the regorus API the resolver depends on.
Add config parsing that prepares a base regorus engine once from global Rego
modules and data documents, failing at load on malformed policy or config. The
resolver clones that base per request, sets the request input, and evaluates
the configured query.

The decision contract maps a boolean, a decision object (allow/deny read from a
configurable field, with reason and violations flowing into the deny), or a
set/array (deny-set idiom: empty allows, non-empty denies) to an allow/deny
decision. An undefined result is a clean deny independent of on_error; genuine
eval errors and non-decision values route through on_error (default deny);
malformed Rego always denies. Inline modules are compiled once into a bounded,
never-evicting cache.

Register the opa PDP behind an optional feature in cpex-builtins, on by
default alongside cedar and cel.
Add an integration test that drives config through the apl-cpex visitor: an
opa PDP declared in YAML with Rego modules and data, queried from a route
step, producing real allow/deny decisions via the dispatcher. Covers the
boolean allow/deny path, the deny-set idiom, external data lookup, load-time
rejection of malformed config, and a missing-query author bug surfacing as a
clean deny.

Extract a merge_data helper so inline data and data files share one JSON
normalization and error-mapping path.
- Reject a non-string on_error/decision_field at load instead of silently
  defaulting, matching the strictness already applied to unknown keys and
  non-sequence modules.
- Document OPA as a shipped default PDP builtin in the dialect table and
  README (it previously read as recognized-but-unimplemented).
- Explain why the cache/coverage regorus features are enabled, matching the
  rationale given for the excluded network builtins.
- Add coverage: a disabled network builtin cannot allow (pins the feature
  gate), module_files/data_files load-and-evaluate plus a missing data file,
  the dialect override, and the decision-object errors-key / id-fallback /
  allow-wins-over-violations paths.
…eval offload

Address code-review findings on the embedded policy path:

- Reject a route-step inline module whose Rego package collides with a global
  module's package (fail-closed). Inline modules may add new packages but can
  no longer merge into and override operator policy — closing an escalation
  path when route config is authored by a less-trusted party than global
  policy.
- Treat a cache-full inline-module rejection as an always-deny condition rather
  than routing it through on_error, so a resource limit cannot fail open under
  on_error: allow.
- Run set_input + eval_rule on a blocking thread so a CPU-heavy Rego evaluation
  cannot monopolize an async worker; a task panic fails closed. Adds a tokio
  runtime dependency (documented divergence from the inline-eval CEL resolver).
- Document the inline-module trust boundary in the crate docs.
…ego PDP

Capture the brainstorm requirements doc and the implementation plan (marked
completed) that drove this feature.
@araujof
araujof requested review from jonpspri and terylt as code owners July 29, 2026 12:23
A spawn_blocking JoinError means the eval task panicked or aborted, which is
an abnormal termination rather than a policy outcome. Route it through the
always-deny path instead of on_error so a panic cannot fail open under
on_error: allow.
@araujof araujof added enhancement New feature or request framework Rust labels Jul 29, 2026
@araujof araujof added this to CPEX Jul 29, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CPEX Jul 29, 2026
@araujof araujof added this to the 0.2.3 milestone Jul 29, 2026
@araujof araujof moved this from Backlog to In review in CPEX Jul 29, 2026
araujof added 3 commits July 30, 2026 21:05
The regorus `cache` feature caches compiled regex and glob patterns in a
process-global LRU keyed by pattern text, not rule-evaluation results. The
previous comment claimed the latter, which reads as a possible cross-input
decision leak.

Record where input isolation actually comes from: `Interpreter::clone` resets
the memo maps and `Engine::eval_rule` clears them before every evaluation. Add
a deterministic sequential-reuse test so a regorus bump that starts retaining
input-derived state fails a test.

Also document why the cold-cache double-prepare in `engine_for` is intentional,
and drop the unused `coverage` feature.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The cache-full warning told operators to raise `with_max_cache_entries`, but
the factory only builds from config and unknown keys are rejected, so an
operator hitting the cap through unified config had no lever at all.

Accept `max_cache_entries` as a config key with the same strictness as the
other fields: a present non-integer or negative value is a load error, not a
silent default. A cap of 0 refuses inline modules while global-module steps
keep evaluating.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A decision object's violations and errors lists, a deny set's elements, and the
whole-object serialization were all unbounded. Their content is policy-authored
and can be derived from arbitrarily large `data`, so a single deny could produce
an unbounded diagnostic payload once an audit sink reads it.

Cap element count and line length on all three paths. Truncation is always
marked so a bounded diagnostic never reads as a complete one, and a deny set's
reason keeps reporting the true violation count even when its diagnostics are
capped.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>

@terylt terylt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@araujof
araujof changed the base branch from main to dev July 31, 2026 13:41
@araujof
araujof merged commit 92ee83a into dev Jul 31, 2026
9 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in CPEX Jul 31, 2026
@araujof
araujof deleted the feat/opa_rs branch July 31, 2026 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request framework Rust

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[FEATURE]: OPA/Rego PDP builtin (regorus)

2 participants