Skip to main content

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}