Skip to main content

apl_core/
step.rs

1// Location: ./crates/apl-core/src/step.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Policy-phase Step IR and async dispatch traits.
7//
8// The DSL allows policy:/post_policy: lists to contain three kinds of
9// entries beyond predicate-and-action rules:
10//
11//   - PDP calls: `cedar:(...)`, `opa(...)`, `authzen(...)`, `nemo(...)`,
12//     `cel:(...)` with optional `on_deny:` / `on_allow:` reaction blocks
13//   - Plugin invocations: `plugin(name)`
14//   - Taint effects: `taint(label[, scope])`
15//
16// `Step` is the union over these forms plus the existing `Rule`. The async
17// `evaluate_steps` function walks a Step list, dispatching PDP calls via
18// `PdpResolver` and plugin calls via `PluginInvoker`. Taint dispatch is
19// recognized but no-op in apl-core — actual SessionStore writes happen in
20// `apl-cpex`, which has access to that machinery.
21//
22// Grounded in apl-dsl-spec.md §3 (effects) / §7 (PDP integration) and
23// apl-design.md §8.1 (PdpResolver seam).
24
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::evaluator::Decision;
30use crate::pipeline::{TaintEvent, TaintScope};
31use crate::rules::Rule;
32
33/// Parser-internal intermediate IR. After the parser builds a Step
34/// tree, `parser::step_to_top_level_effect` converts it into the
35/// unified [`crate::rules::Effect`] used by the evaluator + every
36/// public entry point.
37///
38/// `Step` exists only because `parse_step` builds its nodes
39/// incrementally and the conversion to `Effect::When` /
40/// `Effect::Pdp` happens at the top of `compile_apl_blocks` once
41/// the source position is known. Not part of the public API as of
42/// E4 — external code dispatches on `Effect` everywhere.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub(crate) enum Step {
46    /// Predicate-and-action rule (the existing 5a/5b/5c case).
47    Rule(Rule),
48
49    /// External PDP call. `on_deny` / `on_allow` are reaction Step lists
50    /// that fire based on the PDP's decision (DSL §7.5).
51    Pdp {
52        call: PdpCall,
53        #[serde(default, skip_serializing_if = "Vec::is_empty")]
54        on_deny: Vec<Step>,
55        #[serde(default, skip_serializing_if = "Vec::is_empty")]
56        on_allow: Vec<Step>,
57    },
58
59    /// `plugin(name)` — invoke a CPEX-registered plugin. The plugin's
60    /// `PluginResult` decision becomes the step's outcome.
61    Plugin { name: String },
62
63    /// `delegate: { plugin: ..., ... }` — mint a downstream delegation
64    /// token via a TokenDelegateHook plugin. Populates
65    /// `delegation.granted_*` attributes in the bag so subsequent
66    /// rules in the same step list can read them. See
67    /// `docs/apl-identity-delegation-design.md`.
68    Delegate(DelegateStep),
69
70    /// `taint(label[, scope])` — apply a taint label. Always succeeds;
71    /// never produces a Deny. SessionStore dispatch happens in apl-cpex.
72    Taint {
73        label: String,
74        scopes: Vec<TaintScope>,
75    },
76}
77
78/// One delegation invocation inside `policy:` or `post_policy:`.
79///
80/// At runtime the apl-cpex `DelegationInvoker` constructs a
81/// `cpex_core::delegation::DelegationPayload` from
82///   * the inbound bearer token (pulled from
83///     `Extensions.raw_credentials.inbound_tokens`),
84///   * this step's `args` (target / audience / permissions / mode /
85///     attenuation, layered over the plugin's configured defaults),
86///   * extensions-derived context (subject, prior delegation chain),
87///
88/// then calls `manager.invoke_entries::<TokenDelegateHook>(...)`. On
89/// success the resulting `delegated_token` is written into
90/// `Extensions.raw_credentials.delegated_tokens.*` and the granted
91/// scopes / audience surface as `delegation.granted.*` attributes
92/// in the policy bag for downstream rules to inspect.
93///
94/// `args` is a free-form map because each delegation backend has its
95/// own typed config shape; apl-core treats it as opaque and hands it
96/// to the plugin via the existing per-call config-override pathway.
97///
98/// # Multiple `delegate(...)` in one phase (most-recent-wins)
99///
100/// Multiple `delegate(...)` steps in the same phase are supported —
101/// each fires independently, each contributes to `Extensions`
102/// (`raw_credentials.delegated_tokens` is a HashMap keyed on
103/// audience+scope+mode so tokens accumulate; `delegation.chain`
104/// grows with each hop). But the `delegation.granted.*` bag keys
105/// are **overwritten** on each call — only the most recent
106/// delegate's grants are queryable from downstream `require(...)`
107/// rules.
108///
109/// For fan-out flows that need multiple independently-queryable
110/// grants, split into `policy:` + `post_policy:` or reach for a
111/// future per-step `as:` alias (not in v0; see the design doc's
112/// "Open design questions" section).
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct DelegateStep {
115    /// Plugin name — must reference an entry in the top-level
116    /// `plugins:` block that registers under the `token.delegate`
117    /// hook.
118    pub plugin_name: String,
119
120    /// Per-call config overrides applied for this delegation only.
121    /// Layered on top of the plugin's default config; the framework's
122    /// `build_override_entries` plumbing handles the merge.
123    /// Common keys: `target`, `audience`, `permissions`, `mode`,
124    /// `attenuation`. Schema is plugin-defined.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub config_override: Option<serde_yaml::Value>,
127
128    /// `deny | continue` — what to do when the plugin returns a
129    /// deny (e.g. IdP refusal, network error). `None` defaults to
130    /// `"deny"` (fail-closed; matches PDP step semantics).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub on_error: Option<String>,
133
134    /// Human-readable source path (e.g.
135    /// `"route.get_compensation.policy[2]"`) — used in audit and
136    /// `Decision::Deny.rule_source` when the step denies.
137    pub source: String,
138}
139
140/// A PDP invocation, opaque-args style. Resolvers parse `args` based on
141/// the dialect they handle — apl-core doesn't impose a Cedar/OPA/AuthZen
142/// schema on `args`.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct PdpCall {
145    pub dialect: PdpDialect,
146    /// Dialect-specific call arguments — typically a map for Cedar
147    /// (`action`, `resource`, …) or a string for OPA/AuthZen/NeMo
148    /// (a path or query). Resolvers parse this; apl-core treats it
149    /// as opaque.
150    pub args: serde_yaml::Value,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155#[non_exhaustive]
156pub enum PdpDialect {
157    /// Bare Cedar policy evaluation (`cpex-pdp-cedar-direct`).
158    Cedar,
159    Opa,
160    AuthZen,
161    NeMo,
162    /// CEL (Common Expression Language) evaluation — `cpex-pdp-cel`.
163    /// The `cel:` step carries an `expr:` string that must evaluate to a
164    /// boolean against the policy `AttributeBag` (exposed to CEL as nested
165    /// namespaces: `subject.id`, `delegation.depth`, `session.labels`, …).
166    /// A small, safe, non-Turing-complete predicate language — distinct
167    /// from the full PDPs (Cedar/OPA) so all can coexist on one
168    /// `PdpRouter`. The canonical route-YAML form is the block map
169    /// `cel: { expr: "..." }`; the `cel:(...)` call form is also accepted.
170    Cel,
171    #[serde(untagged)]
172    Custom(String),
173}
174
175impl PdpDialect {
176    /// Parse a YAML key prefix like `cedar`, `opa`, `authzen`, `nemo`
177    /// into the matching `PdpDialect`. Unknown dialects become `Custom`.
178    pub fn from_key(key: &str) -> Self {
179        match key {
180            "cedar" => Self::Cedar,
181            "opa" => Self::Opa,
182            "authzen" => Self::AuthZen,
183            "nemo" => Self::NeMo,
184            "cel" => Self::Cel,
185            other => Self::Custom(other.to_string()),
186        }
187    }
188}
189
190// =====================================================================
191// Resolver traits
192// =====================================================================
193
194/// External policy-decision dispatch. Implemented by Cedar, OPA HTTP
195/// clients, AuthZen clients, NeMo Guardrails — anything that can answer
196/// "given this call, allow or deny?" against a request context.
197///
198/// `apl-cpex` provides the bridge from CPEX plugins (e.g. `cedar-direct`)
199/// to this trait so the host doesn't have to know about the plugin types.
200#[async_trait]
201pub trait PdpResolver: Send + Sync {
202    /// What dialect this resolver handles. The evaluator routes PDP steps
203    /// to the resolver whose `dialect()` matches `Step::Pdp.call.dialect`.
204    fn dialect(&self) -> PdpDialect;
205
206    async fn evaluate(
207        &self,
208        call: &PdpCall,
209        bag: &crate::attributes::AttributeBag,
210    ) -> Result<PdpDecision, PdpError>;
211}
212
213/// Build a [`PdpResolver`] from a unified-config block. Implemented per
214/// PDP backend (cedar-direct, opa, …) and registered with
215/// the apl-cpex visitor so unified-config YAML can declare PDPs
216/// without the host pre-constructing them in code.
217///
218/// Hosts register a factory by handing it to apl-cpex's
219/// `AplOptions.pdp_factories`. When the visitor walks the unified
220/// config and finds a `global.apl.pdp[].kind` matching the factory's
221/// reported `kind()`, it calls `build` with the rest of that block.
222///
223/// The error type is `Box<dyn Error + Send + Sync>` to keep this trait
224/// in apl-core (which has no cpex deps). apl-cpex's visitor wraps
225/// the boxed error into `VisitorError` → `PluginError::Config` at the
226/// manager boundary.
227pub trait PdpFactory: Send + Sync {
228    /// Identifies which `kind:` in a config block this factory handles.
229    /// Convention: kebab-case matching the published PDP product name
230    /// (`"cedar-direct"`, `"opa"`, …).
231    fn kind(&self) -> &str;
232
233    /// Build a resolver from the rest of the PDP config block (everything
234    /// under the same map level as `kind`). Implementations parse their
235    /// own config shape; missing or malformed fields surface here.
236    fn build(
237        &self,
238        config: &serde_yaml::Value,
239    ) -> Result<std::sync::Arc<dyn PdpResolver>, Box<dyn std::error::Error + Send + Sync>>;
240}
241
242/// Where in the request lifecycle a plugin dispatch is happening.
243/// Threads through `PluginInvocation` so the invoker can select the
244/// right hook entry from a plugin that registered for both pre and
245/// post phases (e.g. `cmf.tool_pre_invoke` AND `cmf.tool_post_invoke`).
246///
247/// APL's four phases map to two dispatch phases:
248///   * `args:` field stages    → `Pre`
249///   * `policy:` steps         → `Pre`
250///   * `result:` field stages  → `Post`
251///   * `post_policy:` steps    → `Post`
252///
253/// Plugins that need to discriminate `args` vs `policy` (same `Pre`
254/// from the dispatcher's perspective) inspect `PluginContext::hook_name()`
255/// inside their handler — the hook-routing layer doesn't slice phase
256/// finer than Pre/Post.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum DispatchPhase {
259    Pre,
260    Post,
261}
262
263/// Context for one plugin invocation: tells the invoker the *intent* of
264/// the call so it can dispatch to the right CPEX hook contract.
265///
266/// `Step` is the policy / post_policy case — the invoker (apl-cpex side)
267/// already holds a typed payload reference; APL doesn't need to pass one.
268///
269/// `Field` is the pipe-chain case — APL is focused on a specific field
270/// value mid-transform and the plugin may rewrite that value via
271/// `PluginOutcome.modified_value`.
272///
273/// Both variants carry a `DispatchPhase` so the invoker can resolve the
274/// right hook entry against the cpex-core hook routing table when the
275/// plugin registered for multiple hooks.
276#[derive(Debug, Clone, Copy)]
277pub enum PluginInvocation<'a> {
278    /// Called from a `policy:` or `post_policy:` step. The plugin operates
279    /// on whatever typed payload the invoker was bound to.
280    Step { phase: DispatchPhase },
281    /// Called inside an `args:` / `result:` pipe chain on one field.
282    Field {
283        name: &'a str,
284        value: &'a serde_json::Value,
285        phase: DispatchPhase,
286    },
287}
288
289impl<'a> PluginInvocation<'a> {
290    /// Convenience: the dispatch phase carried by this invocation.
291    pub fn phase(&self) -> DispatchPhase {
292        match self {
293            PluginInvocation::Step { phase } => *phase,
294            PluginInvocation::Field { phase, .. } => *phase,
295        }
296    }
297}
298
299/// Plugin invocation dispatch. apl-cpex wraps the CPEX `PluginManager`
300/// behind this trait so the apl-core evaluator stays free of cpex-core
301/// dependencies.
302#[async_trait]
303pub trait PluginInvoker: Send + Sync {
304    /// Invoke the named plugin against the current request context. The
305    /// `invocation` discriminates step vs pipe-chain call.
306    async fn invoke(
307        &self,
308        name: &str,
309        bag: &crate::attributes::AttributeBag,
310        invocation: PluginInvocation<'_>,
311    ) -> Result<PluginOutcome, PluginError>;
312}
313
314/// Delegation dispatch — invokes a `TokenDelegateHook` plugin to mint
315/// a downstream credential. apl-cpex implements this against
316/// `cpex_core::PluginManager::invoke_entries::<TokenDelegateHook>`.
317///
318/// The invoker holds the request-scoped `Extensions` internally
319/// (same pattern as `CmfPluginInvoker`), so the trait method doesn't
320/// need to pass them — the invoker uses its own snapshot to construct
321/// the `DelegationPayload` (inbound bearer token, subject, prior
322/// delegation chain).
323#[async_trait]
324pub trait DelegationInvoker: Send + Sync {
325    /// Run one delegation step. Returns a `DelegationOutcome` carrying
326    /// the granted permissions / audience / expiry the IdP issued; the
327    /// evaluator writes those into the bag as `delegation.granted_*`
328    /// attributes so subsequent rules in the same step list can
329    /// inspect them via `require(delegation.granted_permissions
330    /// contains "X")` etc.
331    ///
332    /// `step.config_override` is layered on top of the plugin's
333    /// default config and threaded through the standard per-call
334    /// override pathway.
335    async fn delegate(&self, step: &DelegateStep) -> Result<DelegationOutcome, DelegationError>;
336}
337
338/// What a delegation invocation returned.
339///
340/// On success, `decision` is `Allow` and the granted_* fields reflect
341/// what the IdP actually issued (which may be narrower than what the
342/// route asked for — `granted_permissions` is the source of truth for
343/// what the downstream tool will accept). The evaluator surfaces these
344/// into the bag under the `delegation.granted.*` sub-namespace plus a
345/// `delegation.granted = true` flag.
346///
347/// On `Deny`, granted_* fields are empty / `None` and the
348/// `delegation.granted` flag is not set (absent → falsy).
349#[derive(Debug, Clone)]
350pub struct DelegationOutcome {
351    pub decision: Decision,
352    /// Permissions the IdP actually granted on the minted token. Empty
353    /// when the call failed or the plugin returned no token.
354    pub granted_permissions: Vec<String>,
355    /// Audience the minted token is valid for. `None` when no token
356    /// was produced.
357    pub granted_audience: Option<String>,
358    /// Token expiry (RFC 3339 string for bag-friendly representation).
359    /// `None` when no token was produced.
360    pub granted_expires_at: Option<String>,
361}
362
363impl DelegationOutcome {
364    /// Convenience for the "deny, nothing granted" case.
365    pub fn deny(decision: Decision) -> Self {
366        Self {
367            decision,
368            granted_permissions: Vec::new(),
369            granted_audience: None,
370            granted_expires_at: None,
371        }
372    }
373}
374
375#[derive(Debug, Error)]
376pub enum DelegationError {
377    #[error("no delegation invoker available for plugin `{0}`")]
378    NotFound(String),
379
380    #[error("delegation dispatch failed: {0}")]
381    Dispatch(String),
382}
383
384/// `DelegationInvoker` impl that returns `NotFound` for every call.
385/// Useful as the default for evaluator callers that don't run any
386/// `delegate(...)` steps — they need to pass *something* implementing
387/// the trait, but the noop never actually gets invoked. Tests and
388/// hosts that haven't wired a real delegation backend pass this.
389pub struct NoopDelegationInvoker;
390
391#[async_trait]
392impl DelegationInvoker for NoopDelegationInvoker {
393    async fn delegate(&self, step: &DelegateStep) -> Result<DelegationOutcome, DelegationError> {
394        Err(DelegationError::NotFound(step.plugin_name.clone()))
395    }
396}
397
398// =====================================================================
399// Resolver results
400// =====================================================================
401
402/// What a PDP returned.
403#[derive(Debug, Clone, PartialEq)]
404pub struct PdpDecision {
405    pub decision: Decision,
406    /// Optional diagnostic info: matched policy IDs, error codes, etc.
407    /// Surfaces in audit logs; not used for control flow.
408    pub diagnostics: Vec<String>,
409}
410
411/// What a plugin returned.
412#[derive(Debug, Clone)]
413pub struct PluginOutcome {
414    pub decision: Decision,
415    /// Plugins may apply taint labels as a side effect. Same shape as
416    /// config-emitted taints (`Step::Taint` / `Stage::Taint`) so the
417    /// downstream evaluator can append both into a single
418    /// `Vec<TaintEvent>` without converting. Each event may carry
419    /// multiple scopes — `CmfPluginInvoker` uses single-scope
420    /// (`Session`) for v0 but future invokers and plugins that emit
421    /// directly are free to span scopes.
422    pub taints: Vec<TaintEvent>,
423    /// Pipe-context return: when a plugin runs as a stage inside an
424    /// args/result chain, it may rewrite the field value (e.g., a PII
425    /// scrubber producing a redacted string). `None` means "leave value
426    /// unchanged"; always `None` for policy / post_policy invocations.
427    pub modified_value: Option<serde_json::Value>,
428}
429
430impl PluginOutcome {
431    /// Convenience for the common "allow, no taints, no value change" case.
432    pub fn allow() -> Self {
433        Self {
434            decision: Decision::Allow,
435            taints: vec![],
436            modified_value: None,
437        }
438    }
439}
440
441// =====================================================================
442// Errors
443// =====================================================================
444
445#[derive(Debug, Error)]
446pub enum PdpError {
447    #[error("no PDP resolver registered for dialect {0:?}")]
448    NoResolver(PdpDialect),
449
450    #[error("PDP dispatch failed: {0}")]
451    Dispatch(String),
452}
453
454#[derive(Debug, Error)]
455pub enum PluginError {
456    #[error("no plugin invoker available for `{0}`")]
457    NotFound(String),
458
459    #[error("plugin dispatch failed: {0}")]
460    Dispatch(String),
461}
462
463// =====================================================================
464// Convenience
465// =====================================================================
466
467impl Step {
468    /// Wrap a `Rule` as a `Step`. Saves typing in tests and parser code.
469    pub fn rule(r: Rule) -> Self {
470        Step::Rule(r)
471    }
472
473    /// Returns true if this step is a plain rule (no async dispatch needed).
474    pub fn is_rule(&self) -> bool {
475        matches!(self, Step::Rule(_))
476    }
477}
478
479/// Bag keys the delegation step writes after a successful dispatch.
480/// Centralized here so the evaluator (writer) and policy authors
481/// (readers, via `require(delegation.granted.*)`) agree on the
482/// canonical names — typos in either place silently break the
483/// IdP-as-PDP pattern.
484///
485/// # Namespace
486///
487/// The `delegation.*` namespace at the top level carries INBOUND
488/// chain attributes (`delegation.depth`, `delegation.origin`,
489/// `delegation.chain`, ...) populated by identity resolver plugins
490/// via `IdentityPayload.delegation` + apply-to-extensions, then
491/// surfaced through apl-cmf's BagBuilder. See
492/// `docs/specs/delegation-hooks-rust-spec.md` §6.3 for that mapping.
493///
494/// The `delegation.granted.*` sub-namespace defined here is for
495/// OUTBOUND results — what came back from a `delegate(...)` step
496/// the framework just ran. Two writers (identity plugin for inbound,
497/// `delegate(...)` for outbound), distinct sub-trees, no collision.
498pub mod delegation_bag_keys {
499    /// `StringSet` — permissions actually granted by the IdP on the
500    /// minted token. May be narrower than `required_permissions`.
501    pub const GRANTED_PERMISSIONS: &str = "delegation.granted.permissions";
502    /// `String` — audience the minted token is valid for.
503    pub const GRANTED_AUDIENCE: &str = "delegation.granted.audience";
504    /// `String` — token expiry as RFC 3339.
505    pub const GRANTED_EXPIRES_AT: &str = "delegation.granted.expires_at";
506    /// `Bool` — set to `true` after a successful `delegate(...)`
507    /// step. Lets policy branch on success without inspecting the
508    /// granted_permissions set: `require(delegation.granted)`. Absent
509    /// (i.e. evaluates to false) when no delegate step has run OR
510    /// when the most recent one denied.
511    pub const GRANTED: &str = "delegation.granted";
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn from_key_maps_known_dialects() {
520        assert_eq!(PdpDialect::from_key("cedar"), PdpDialect::Cedar);
521        assert_eq!(PdpDialect::from_key("opa"), PdpDialect::Opa);
522        assert_eq!(PdpDialect::from_key("authzen"), PdpDialect::AuthZen);
523        assert_eq!(PdpDialect::from_key("nemo"), PdpDialect::NeMo);
524        assert_eq!(PdpDialect::from_key("cel"), PdpDialect::Cel);
525    }
526
527    #[test]
528    fn from_key_unknown_is_custom() {
529        assert_eq!(
530            PdpDialect::from_key("rego-remote"),
531            PdpDialect::Custom("rego-remote".to_string())
532        );
533    }
534
535    #[test]
536    fn cel_dialect_serde_roundtrips_as_snake_case() {
537        // `Cel` is a tagged variant (snake_case) — must round-trip so
538        // compiled-route serialization (audit/cache) preserves it.
539        let json = serde_json::to_string(&PdpDialect::Cel).unwrap();
540        assert_eq!(json, "\"cel\"");
541        let back: PdpDialect = serde_json::from_str(&json).unwrap();
542        assert_eq!(back, PdpDialect::Cel);
543    }
544}