praxis_policy/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4//! **PPE is a policy enforcement runtime for AI agents.**
5//!
6//! It is a deterministic reference monitor between an agent and every
7//! capability it invokes: tools, prompts, resources, inference providers, and
8//! A2A methods. Each operation runs through a policy-defined pipeline that can
9//! resolve identity, make an authorization decision (delegated to an engine
10//! like Cedar or CEL), exchange and reduce credentials before a downstream
11//! call, redact inputs and outputs, track information flow across calls, and
12//! audit. You write that policy declaratively in APL, the configuration that
13//! defines each operation's pipeline; PPE evaluates and enforces it at the
14//! boundary, against state the model cannot observe or forge.
15//!
16//! - Source and issues: <https://github.com/praxis-proxy/policy>
17//!
18//! # This crate
19//!
20//! `praxis-policy` is the **host facade**: one dependency that re-exports the PPE
21//! runtime (`praxis-policy-core`, `praxis-policy-apl-core`, `praxis-policy-apl-cmf`, `praxis-policy-apl-runtime`), so a host depends
22//! on this crate instead of pinning each of them separately.
23//!
24//! By default it is the **engine only**: no builtin plugins are compiled in.
25//! The bundled plugins, PDPs and session stores are registered from here, each
26//! behind a feature, and only what you enable is compiled.
27//!
28//! # Usage
29//!
30//! Engine only (register your own factories):
31//!
32//! ```no_run
33//! use std::sync::Arc;
34//! use praxis_policy::PolicyEngine;
35//!
36//! let mgr = Arc::new(PolicyEngine::default());
37//! // ... register host factories, then `praxis_policy_apl_runtime::register_apl(&mgr, opts)`.
38//! ```
39//!
40//! With the bundled builtins (enable the `builtins` feature):
41//!
42//! ```ignore
43//! use std::sync::Arc;
44//! use praxis_policy::PolicyEngine;
45//!
46//! let mgr = Arc::new(PolicyEngine::default());
47//! // Register every enabled builtin factory and install the APL config
48//! // visitor (in-process defaults) in one call:
49//! praxis_policy::install_builtins(&mgr);
50//! // ... then load a config that references the enabled `kind`s.
51//! ```
52//!
53//! # Features
54//!
55//! No plugins are on by default (`praxis-policy` alone is the engine).
56//! `builtins` enables every bundled extension, including the Valkey session
57//! store; or pick a granular subset (`jwt`, `api-key`, `oauth`, `elicitation-ciba`,
58//! `cedar`, `cel`, `opa`, `valkey`, `secrets-vault`). Any of them brings in the registration
59//! helpers, and each one re-exports its own concrete factory type here.
60//!
61//! # Plugins the host supplies
62//!
63//! A plugin does not have to be bundled. Implement [`PluginFactory`] and hand it
64//! to [`PolicyEngine::register_factory`] under the `kind:` your YAML names;
65//! [`prelude`] is the surface to write it against. An unrecognised `kind` is a
66//! load-time error, so a missing registration fails at startup rather than
67//! silently skipping the plugin.
68//!
69//! `reference/plugins/` in this repository holds two worked examples, a PII
70//! scanner and an audit logger. Neither is published or bundled; a host registers
71//! them.
72
73// Whole-crate re-exports for advanced use (types not surfaced below).
74
75pub use {
76 praxis_policy_apl_cmf, praxis_policy_apl_core, praxis_policy_apl_runtime, praxis_policy_core,
77};
78
79pub use praxis_policy_apl_core::step::PdpFactory;
80pub use praxis_policy_apl_runtime::{
81 AplOptions, DispatchCache, MemorySessionStore, SessionStore, SessionStoreFactory, register_apl,
82};
83pub use praxis_policy_core::engine::PolicyEngine;
84
85/// The two types a host needs to accept a plugin it did not compile in:
86/// [`PolicyEngine::register_factory`] takes a `Box<dyn PluginFactory>`, and
87/// [`PluginInstance`] is what that factory returns.
88///
89/// Surfaced here so a host embedding the engine can name them without reaching
90/// through to `praxis_policy_core`. Plugin *authors* get the same two names from
91/// [`prelude`].
92pub use praxis_policy_core::factory::{PluginFactory, PluginInstance};
93
94/// What a host needs to give the engine its secrets.
95///
96/// A host builds a [`SecretProviderRegistry`] holding the backends this build
97/// carries and installs it with `PolicyEngine::set_secret_providers` before
98/// `initialize()`. [`SecretProviderFactory`] is what a host implements to add a
99/// backend of its own, and [`SecretRef`] is the handle a consumer keeps so a
100/// later refresh reaches it. A consumer that rebuilds something on rotation
101/// reads [`SecretRef::snapshot`], which pairs the value with the generation it
102/// belongs to.
103///
104/// `env` and `file` are always registered by
105/// [`SecretProviderRegistry::with_builtin_backends`]. Vault KV v2 is behind
106/// the `secrets-vault` feature: the host constructs `VaultSecretProviderFactory`
107/// with its `HttpTransport` and registers it (or calls `registry_with_vault`).
108/// Nothing here spawns a Vault session ticker; token renewal is lazy on
109/// the next read.
110///
111/// There is no handle here that reads every declared value. A host drives
112/// refresh with `PolicyEngine::refresh_secrets` and watches for staleness with
113/// `PolicyEngine::secrets_last_success`, neither of which yields secret
114/// material.
115pub use praxis_policy_core::secrets::{
116 ProviderFailure, ProviderHealth, RefreshReport, SecretError, SecretProvider,
117 SecretProviderFactory, SecretProviderRegistry, SecretRef, SecretSnapshot, SecretsHealth,
118};
119
120/// Curated re-exports for plugin authors, so a plugin crate can depend on this
121/// facade alone. See [`praxis_policy_core::prelude`].
122pub use praxis_policy_core::prelude;
123
124// Concrete factory types + KIND consts, each behind its feature.
125#[cfg(feature = "cedar")]
126pub use praxis_policy_builtins::pdps::cedar_direct::CedarDirectPdpFactory;
127#[cfg(feature = "cel")]
128pub use praxis_policy_builtins::pdps::cel::CelPdpFactory;
129#[cfg(feature = "opa")]
130pub use praxis_policy_builtins::pdps::opa::OpaPdpFactory;
131#[cfg(feature = "oauth")]
132pub use praxis_policy_builtins::plugins::delegator_oauth::{
133 KIND as OAUTH_KIND, OAuthDelegatorFactory,
134};
135#[cfg(feature = "delegator-vault")]
136pub use praxis_policy_builtins::plugins::delegator_vault::{
137 KIND as VAULT_DELEGATOR_KIND, VaultDelegatorFactory,
138};
139#[cfg(feature = "elicitation-ciba")]
140pub use praxis_policy_builtins::plugins::elicitation_ciba::{
141 CibaApproverFactory, KIND as CIBA_KIND,
142};
143#[cfg(feature = "api-key")]
144pub use praxis_policy_builtins::plugins::identity_api_key::{
145 ApiKeyIdentityFactory, KIND as API_KEY_KIND,
146};
147#[cfg(feature = "jwt")]
148pub use praxis_policy_builtins::plugins::identity_jwt::{JwtIdentityFactory, KIND as JWT_KIND};
149#[cfg(feature = "experimental-quota")]
150pub use praxis_policy_builtins::plugins::quota::{KIND as QUOTA_KIND, QuotaFactory};
151#[cfg(feature = "secrets-vault")]
152pub use praxis_policy_builtins::secrets::vault::{
153 KIND as VAULT_SECRET_KIND, VaultSecretProviderFactory,
154 register as register_vault_secret_provider, registry_with_vault,
155};
156#[cfg(feature = "valkey")]
157pub use praxis_policy_builtins::session::valkey::{
158 KIND as VALKEY_KIND, ValkeyConfig, ValkeySessionStoreFactory,
159};
160
161// =============================================================================
162// Builtin registration
163// =============================================================================
164//
165// The feature list, the factory re-exports above, and the registration table
166// below all describe the same set, so they belong in one crate. Split across two,
167// each side needs its own umbrella feature forwarding to the other, and the two
168// can disagree: an umbrella that compiles every builtin in while exporting none
169// of their types still builds.
170
171/// Generate [`register_builtin_plugins`] from a feature to factory table. Each
172/// entry expands to a `#[cfg(feature = ...)]`-gated, **explicit**
173/// `register_factory(KIND, Box::new(Factory))` call keyed off the builtin
174/// crate's own `KIND` const.
175///
176/// Explicit calls (rather than `inventory` / `linkme` link-section registration)
177/// are deliberate: when this engine is linked into an FFI staticlib the linker
178/// garbage-collects
179/// sections nothing references, which would silently drop auto-registered
180/// plugins. Naming each factory here keeps its object code alive.
181#[cfg(feature = "_builtin")]
182macro_rules! register_builtins {
183 ( $( feature $feat:literal => $krate:ident :: $factory:ident ),* $(,)? ) => {
184 /// Register every enabled by-kind plugin factory on `mgr`: identity
185 /// (`jwt`), delegators (`oauth`), and elicitation approvers
186 /// (`elicitation-ciba`). Call before loading a config so the engine can
187 /// instantiate plugins whose YAML `kind:` matches.
188 ///
189 /// A host adds its own with [`PolicyEngine::register_factory`], after
190 /// this call so a host registration wins on a shared `kind`.
191 ///
192 /// PDP and session-store factories are wired through [`AplOptions`]
193 /// instead; see [`builtin_pdp_factories`] and
194 /// [`builtin_session_store_factories`], or use [`install_builtins`].
195 #[allow(unused_variables)]
196 pub fn register_builtin_plugins(mgr: &std::sync::Arc<PolicyEngine>) {
197 $(
198 #[cfg(feature = $feat)]
199 mgr.register_factory($krate::KIND, Box::new($krate::$factory));
200 )*
201 }
202 };
203}
204
205// Module aliases for the `register_builtins!` arms below. The macro matches a
206// single identifier and uses it for both `KIND` and the factory type, which a
207// multi-segment module path cannot satisfy; aliasing keeps the registration
208// keyed off each extension's own `KIND` const.
209#[cfg(feature = "oauth")]
210use praxis_policy_builtins::plugins::delegator_oauth as oauth_builtin;
211#[cfg(feature = "delegator-vault")]
212use praxis_policy_builtins::plugins::delegator_vault as vault_delegator_builtin;
213#[cfg(feature = "elicitation-ciba")]
214use praxis_policy_builtins::plugins::elicitation_ciba as ciba_builtin;
215#[cfg(feature = "api-key")]
216use praxis_policy_builtins::plugins::identity_api_key as api_key_builtin;
217#[cfg(feature = "jwt")]
218use praxis_policy_builtins::plugins::identity_jwt as jwt_builtin;
219#[cfg(feature = "experimental-quota")]
220use praxis_policy_builtins::plugins::quota as quota_builtin;
221
222#[cfg(feature = "_builtin")]
223register_builtins! {
224 feature "jwt" => jwt_builtin::JwtIdentityFactory,
225 feature "api-key" => api_key_builtin::ApiKeyIdentityFactory,
226 feature "oauth" => oauth_builtin::OAuthDelegatorFactory,
227 feature "delegator-vault" => vault_delegator_builtin::VaultDelegatorFactory,
228 feature "elicitation-ciba" => ciba_builtin::CibaApproverFactory,
229 // Experimental: registers only when `experimental-quota` is named, never via `builtins`.
230 feature "experimental-quota" => quota_builtin::QuotaFactory,
231}
232
233/// The enabled PDP factories, ready to drop into
234/// [`AplOptions::pdp_factories`]. A route's `cedar:`, `cel:` or `opa:` step
235/// selects which one runs.
236// `vec![]` can't replace the conditional pushes: each element is
237// `#[cfg]`-gated on its feature, so the set is built incrementally.
238#[cfg(feature = "_builtin")]
239#[allow(unused_mut, clippy::vec_init_then_push)]
240pub fn builtin_pdp_factories() -> Vec<std::sync::Arc<dyn PdpFactory>> {
241 let mut factories: Vec<std::sync::Arc<dyn PdpFactory>> = Vec::new();
242 #[cfg(feature = "cedar")]
243 factories.push(std::sync::Arc::new(CedarDirectPdpFactory::new()));
244 #[cfg(feature = "cel")]
245 factories.push(std::sync::Arc::new(CelPdpFactory::new()));
246 #[cfg(feature = "opa")]
247 factories.push(std::sync::Arc::new(OpaPdpFactory::new()));
248 factories
249}
250
251/// The enabled session-store factories, ready to drop into
252/// [`AplOptions::session_store_factories`]. A `global.session_store:
253/// { kind: ... }` config block selects one; absent that, the in-process
254/// [`MemorySessionStore`] default stays active.
255#[cfg(feature = "_builtin")]
256#[allow(unused_mut, clippy::vec_init_then_push)]
257pub fn builtin_session_store_factories() -> Vec<std::sync::Arc<dyn SessionStoreFactory>> {
258 let mut factories: Vec<std::sync::Arc<dyn SessionStoreFactory>> = Vec::new();
259 #[cfg(feature = "valkey")]
260 factories.push(std::sync::Arc::new(ValkeySessionStoreFactory::new()));
261 factories
262}
263
264/// Register every enabled plugin factory and install the APL config visitor on
265/// `mgr` with in-process defaults (a [`MemorySessionStore`] and the default
266/// baseline capabilities). The enabled PDP and session-store factories are wired
267/// in, so a later config load can reference any of them by `kind`.
268///
269/// Secret backends are not registered here. `env` and `file` live on
270/// `SecretProviderRegistry::with_builtin_backends`. Vault (`kind: vault`)
271/// is compiled with the `secrets-vault` feature but still needs
272/// `register_vault_secret_provider` with a host `HttpTransport`.
273///
274/// This is the one-call path; reach for [`register_builtin_plugins`] and
275/// [`AplOptions`] directly when you need to customize capabilities or the
276/// default store.
277#[cfg(feature = "_builtin")]
278pub fn install_builtins(mgr: &std::sync::Arc<PolicyEngine>) {
279 register_builtin_plugins(mgr);
280
281 let mut opts = AplOptions::in_process();
282 opts.pdp_factories = builtin_pdp_factories();
283 opts.session_store_factories = builtin_session_store_factories();
284
285 let _visitor = register_apl(mgr, opts);
286}
287
288/// Warn that experimental extensions are compiled in. Gated on `experimental`,
289/// which every `experimental-*` feature sets; call at host startup.
290#[cfg(feature = "experimental")]
291pub fn warn_experimental_features() {
292 tracing::warn!("experimental features are enabled that should not be used in production");
293}
294
295/// A default `HttpTransport` on hyper, for hosts that inject none.
296///
297/// Available with the `http-hyper` feature.
298#[cfg(feature = "http-hyper")]
299pub mod http_hyper;
300
301#[cfg(feature = "http-hyper")]
302pub use http_hyper::HyperTransport;
303
304/// Install the bundled hyper transport so plugins can perform outbound
305/// HTTP.
306///
307/// PPE performs no HTTP itself, so without a transport a plugin that
308/// needs one — a JWKS fetch, a token exchange — fails at initialization
309/// with a message saying so. A host embedding PPE in a process that
310/// already has an HTTP stack should install *that* instead, via
311/// [`PolicyEngine::set_http_transport`], so the process keeps one
312/// connection pool and one egress path.
313///
314/// Deliberately not folded into [`install_builtins`]. A host calling
315/// `install_builtins` should not acquire a second HTTP stack because
316/// some unrelated crate in its dependency graph happened to turn this
317/// feature on. Wiring an egress path is worth one explicit line.
318///
319/// The transport builds its pool on first use, so calling this from a
320/// short-lived initialization runtime is safe.
321///
322/// Destinations in the shared address table — loopback, RFC 1918,
323/// link-local (including cloud metadata), CGNAT — are refused as
324/// [`praxis_policy_core::http::HttpTransportError::Rejected`], including
325/// a hostname that resolves only to those addresses. A local `IdP` or
326/// in-cluster Vault needs
327/// [`HyperTransport::with_allow_private_destinations`] installed via
328/// [`PolicyEngine::set_http_transport`] instead of this helper.
329///
330/// Returns `false` if a transport was already installed, in which case
331/// the existing one is kept.
332#[cfg(feature = "http-hyper")]
333pub fn install_default_http_transport(mgr: &std::sync::Arc<PolicyEngine>) -> bool {
334 mgr.set_http_transport(std::sync::Arc::new(http_hyper::HyperTransport::new()))
335}
336
337/// Register the enabled builtins and use hyper when the host has not already
338/// installed an HTTP transport.
339///
340/// Call [`PolicyEngine::set_http_transport`] first when the host has its own
341/// transport. This helper keeps that transport and returns `false`; otherwise
342/// it installs the bundled hyper transport and returns `true`. The bundled
343/// transport refuses private destinations by default, so an in-cluster
344/// Limitador needs a host transport configured to permit that destination.
345#[cfg(all(feature = "_builtin", feature = "http-hyper"))]
346pub fn install_builtins_with_default_http_transport(mgr: &std::sync::Arc<PolicyEngine>) -> bool {
347 let installed = install_default_http_transport(mgr);
348 install_builtins(mgr);
349 installed
350}
351
352#[cfg(all(test, feature = "_builtin"))]
353mod tests {
354 use super::*;
355 use std::sync::Arc;
356
357 #[test]
358 fn install_builtins_runs_without_panic() {
359 let mgr = Arc::new(PolicyEngine::default());
360 install_builtins(&mgr);
361 }
362
363 #[cfg(feature = "http-hyper")]
364 #[test]
365 fn combined_bootstrap_installs_hyper_only_without_an_injected_transport() {
366 let standalone = Arc::new(PolicyEngine::default());
367 assert!(install_builtins_with_default_http_transport(&standalone));
368
369 let embedded = Arc::new(PolicyEngine::default());
370 assert!(embedded.set_http_transport(Arc::new(
371 HyperTransport::new().with_allow_private_destinations()
372 )));
373 assert!(!install_builtins_with_default_http_transport(&embedded));
374 }
375
376 #[test]
377 fn pdp_factories_track_enabled_features() {
378 let expected = usize::from(cfg!(feature = "cedar"))
379 + usize::from(cfg!(feature = "cel"))
380 + usize::from(cfg!(feature = "opa"));
381 assert_eq!(
382 builtin_pdp_factories().len(),
383 expected,
384 "one PDP factory per enabled feature",
385 );
386 }
387
388 #[test]
389 fn every_builtin_pdp_kind_is_in_the_differential_harness() {
390 // Source of truth is the harness crate, not a local copy. A new
391 // factory in `builtin_pdp_factories` that is not on that list means
392 // the dialect was added to the router/facade without the differential
393 // harness — which is what issue #25 forbids.
394 use praxis_policy_pdp_diff::HARNESS_PDP_KINDS;
395 for factory in builtin_pdp_factories() {
396 assert!(
397 HARNESS_PDP_KINDS.contains(&factory.kind()),
398 "kind '{}' is registered in builtin_pdp_factories but not in \
399 the ppe-pdp-diff harness. Add a Dialect arm and catalog \
400 coverage there; registering it on PdpRouter is not enough.",
401 factory.kind()
402 );
403 }
404 }
405
406 #[test]
407 fn session_store_factories_track_enabled_features() {
408 let expected = usize::from(cfg!(feature = "valkey"));
409 assert_eq!(
410 builtin_session_store_factories().len(),
411 expected,
412 "one session-store factory per enabled feature",
413 );
414 }
415
416 /// Load a one-plugin config against a engine with the builtins installed,
417 /// and return the error text (empty string on success).
418 ///
419 /// Goes through `load_config_yaml` rather than inspecting the registry
420 /// because that is the path an operator hits: the question is whether their
421 /// YAML `kind:` resolves, not what the map contains.
422 fn load_error_for_kind(kind: &str) -> String {
423 let mgr = Arc::new(PolicyEngine::default());
424 install_builtins(&mgr);
425 let yaml =
426 format!("plugins:\n - name: probe\n kind: {kind}\n hooks: [identity.resolve]\n");
427 match mgr.load_config_yaml(&yaml) {
428 Ok(()) => String::new(),
429 Err(e) => format!("{e}"),
430 }
431 }
432
433 /// The by-kind plugin table had no test, unlike the PDP and session-store
434 /// lists. So a builtin could leave the umbrella, or quietly rejoin it, with
435 /// nothing failing. This pins the set.
436 ///
437 /// Each enabled builtin must resolve its `kind`. The probe config is
438 /// deliberately minimal, so most of these still fail on their own settings —
439 /// what matters is that they fail on settings rather than on a missing
440 /// factory, which is a different message and a different operator problem.
441 #[test]
442 fn every_enabled_builtin_resolves_its_kind() {
443 let expected = [
444 (cfg!(feature = "jwt"), "identity/jwt"),
445 (cfg!(feature = "api-key"), "identity/api-key"),
446 (cfg!(feature = "oauth"), "delegator/oauth"),
447 (cfg!(feature = "delegator-vault"), "delegator/vault"),
448 (cfg!(feature = "elicitation-ciba"), "elicitation/ciba"),
449 ];
450 for (enabled, kind) in expected {
451 if !enabled {
452 continue;
453 }
454 let err = load_error_for_kind(kind);
455 assert!(
456 !err.contains("no factory registered"),
457 "{kind} is enabled, so its factory must be registered; got: {err}"
458 );
459 }
460 }
461
462 /// The PII scanner and audit logger are reference implementations now, so the
463 /// umbrella must not register them. A host supplies them instead.
464 ///
465 /// Asserted as the exact operator-visible failure, because that message is
466 /// what tells someone their config needs a host registration rather than a
467 /// different feature flag.
468 #[test]
469 fn the_reference_plugins_are_not_registered_by_the_umbrella() {
470 for kind in ["validator/pii-scan", "audit/logger"] {
471 let err = load_error_for_kind(kind);
472 assert!(
473 err.contains("no factory registered"),
474 "{kind} must not be bundled; got: {err}"
475 );
476 assert!(
477 err.contains(kind),
478 "the error must name the unresolved kind: {err}"
479 );
480 }
481 }
482
483 /// quota is experimental: `builtins` alone must not register it.
484 #[cfg(not(feature = "experimental-quota"))]
485 #[test]
486 fn quota_is_not_registered_without_the_experimental_feature() {
487 let err = load_error_for_kind("quota/limitador");
488 assert!(
489 err.contains("no factory registered"),
490 "quota must not register without experimental-quota; got: {err}"
491 );
492 }
493
494 /// With `experimental-quota` named, `install_builtins` registers quota (it
495 /// then fails on config, not on a missing factory).
496 #[cfg(feature = "experimental-quota")]
497 #[test]
498 fn quota_registers_with_the_experimental_feature() {
499 let err = load_error_for_kind("quota/limitador");
500 assert!(
501 !err.contains("no factory registered"),
502 "experimental-quota is on, so quota must register; got: {err}"
503 );
504 }
505}