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}