Skip to main content

apl_cpex/
route_handler.rs

1// Location: ./crates/apl-cpex/src/route_handler.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor, Fred Araujo
5//
6// `AplRouteHandler` — synthetic plugin that drives APL evaluation when
7// cpex-core's `filter_entries_by_route` matches an annotated route. Each
8// instance is bound to ONE phase (Pre or Post) so the unified-config
9// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry
10// distinct handler logic without an in-handler hook-name discriminator.
11//
12// # Why a phase-bound handler
13//
14// The CPEX manager's annotation table is keyed on
15// `(entity_type, entity_name, scope, hook_name)`. The visitor registers
16// one handler per route per phase; the manager picks the right one based
17// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is
18// needed — the handler already knows which phase it's running.
19//
20// # Lifetime / weak manager handle
21//
22// The handler holds `Weak<PluginManager>` because the manager owns the
23// snapshot that owns the annotation that owns the handler — a strong
24// reference would create a cycle. Each `invoke` upgrades to `Arc` for
25// the duration of the call. If the upgrade fails (manager has been
26// dropped) the call returns a configuration error.
27
28use std::sync::{Arc, Weak};
29
30use async_trait::async_trait;
31use serde_json::Value;
32
33use cpex_core::cmf::MessagePayload;
34use cpex_core::context::PluginContext;
35use cpex_core::error::{PluginError, PluginViolation};
36use cpex_core::executor::ErasedResultFields;
37use cpex_core::extensions::Extensions;
38use cpex_core::hooks::PluginPayload;
39use cpex_core::manager::PluginManager;
40use cpex_core::plugin::{Plugin, PluginConfig};
41use cpex_core::registry::AnyHookHandler;
42
43use apl_cmf::{extract_args, extract_result, BagBuilder};
44use apl_core::evaluator::Decision;
45use apl_core::plugin_decl::PluginRegistry;
46use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
47use apl_core::rules::CompiledRoute;
48use apl_core::step::PdpResolver;
49
50use crate::cmf_invoker::CmfPluginInvoker;
51use crate::delegation_invoker::DelegationPluginInvoker;
52use crate::dispatch_plan::DispatchCache;
53use crate::pdp_router::PdpRouter;
54use crate::session_store::SessionStore;
55
56/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post
57/// covers `result` + `post_policy`. Set once at construction and never
58/// changes.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Phase {
61    Pre,
62    Post,
63}
64
65/// Synthetic plugin that drives APL evaluation for one route + one phase.
66///
67/// Implements `Plugin` (so cpex-core treats it like any other plugin —
68/// mode/capabilities/on_error come from the `PluginConfig` the visitor
69/// supplied at `annotate_route` time) and `AnyHookHandler` (so the
70/// executor dispatches into it through the normal type-erased path).
71pub struct AplRouteHandler {
72    config: PluginConfig,
73    route: Arc<CompiledRoute>,
74    phase: Phase,
75    plugin_registry: Arc<PluginRegistry>,
76    dispatch_cache: Arc<DispatchCache>,
77    session_store: Arc<dyn SessionStore>,
78    /// Weak handle to the manager so we can resolve plugin entries +
79    /// dispatch into them by-name. `Weak` avoids the
80    /// manager↔snapshot↔annotation↔handler cycle.
81    manager: Weak<PluginManager>,
82    /// PDP resolver. APL routes that don't use `pdp(...)` steps never
83    /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)`
84    /// step against an unregistered dialect returns
85    /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc.
86    /// install resolvers via [`Self::with_pdp`] or
87    /// [`Self::with_pdp_router`].
88    pdp: Arc<dyn PdpResolver>,
89}
90
91impl AplRouteHandler {
92    /// Build a handler. Visitor calls this twice per route — once for
93    /// each phase — and passes the resulting `Arc` to `annotate_route`.
94    pub fn new(
95        config: PluginConfig,
96        route: Arc<CompiledRoute>,
97        phase: Phase,
98        plugin_registry: Arc<PluginRegistry>,
99        dispatch_cache: Arc<DispatchCache>,
100        session_store: Arc<dyn SessionStore>,
101        manager: Weak<PluginManager>,
102    ) -> Self {
103        Self {
104            config,
105            route,
106            phase,
107            plugin_registry,
108            dispatch_cache,
109            session_store,
110            manager,
111            pdp: Arc::new(PdpRouter::new()),
112        }
113    }
114
115    /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs
116    /// to support multiple dialects (Cedar + OPA + NeMo) on the same
117    /// route — the router dispatches each `pdp(...)` step by dialect.
118    /// Pass a single resolver when only one dialect is in use; APL
119    /// steps for any other dialect will then return
120    /// `PdpError::NoResolver` at evaluation time.
121    pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
122        self.pdp = pdp;
123        self
124    }
125
126    /// Sugar for the common "register many resolvers" path. Builds a
127    /// [`PdpRouter`], registers each resolver into it, then installs the
128    /// router. Equivalent to constructing a `PdpRouter` by hand and
129    /// passing it to [`Self::with_pdp`].
130    pub fn with_pdp_router(
131        mut self,
132        resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
133    ) -> Self {
134        let mut router = PdpRouter::new();
135        for r in resolvers {
136            router.register(r);
137        }
138        self.pdp = Arc::new(router);
139        self
140    }
141}
142
143#[async_trait]
144impl Plugin for AplRouteHandler {
145    fn config(&self) -> &PluginConfig {
146        &self.config
147    }
148}
149
150#[async_trait]
151impl AnyHookHandler for AplRouteHandler {
152    async fn invoke(
153        &self,
154        payload: &dyn PluginPayload,
155        extensions: &Extensions,
156        _ctx: &mut PluginContext,
157    ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
158        // Downcast to the CMF payload — this handler only registers for
159        // cmf.* hook names, so the executor should always hand us a
160        // MessagePayload. A mismatch indicates a framework wiring bug.
161        let msg_payload = payload
162            .as_any()
163            .downcast_ref::<MessagePayload>()
164            .ok_or_else(|| {
165                Box::new(PluginError::Config {
166                    message: format!(
167                        "AplRouteHandler '{}': payload was not MessagePayload",
168                        self.route.route_key
169                    ),
170                })
171            })?;
172
173        let manager = self.manager.upgrade().ok_or_else(|| {
174            Box::new(PluginError::Config {
175                message: format!(
176                    "AplRouteHandler '{}': PluginManager dropped before invoke",
177                    self.route.route_key
178                ),
179            })
180        })?;
181
182        // Build (or reuse) the dispatch plan for this route. Cache keyed
183        // by `(route_key, manager.config_generation())` — if the manager
184        // has reloaded since the last invoke, the next lookup rebuilds.
185        let plan = self
186            .dispatch_cache
187            .get_or_build(&self.route, &self.plugin_registry, &manager)
188            .await;
189
190        // CmfPluginInvoker carries the request-scoped payload + extensions
191        // under interior mutability so successive plugin calls accumulate
192        // mutations. Hydration + persistence are no-ops when there's no
193        // session id (the common case for the first request in a session).
194        // Wrapped in Arc so it can be erased to `Arc<dyn PluginInvoker>`
195        // for the apl-core entry points (which take `&Arc<dyn PluginInvoker>`
196        // so `dispatch_parallel` can clone an owned, 'static reference into
197        // each spawned branch). Inherent-method calls on `CmfPluginInvoker`
198        // (e.g. `extensions_arc`, `persist_session`) deref through the Arc.
199        // Hydration loads accumulated session labels. A store failure
200        // here happens *before* any policy decision, so we fail the
201        // request closed immediately (R5/R18, F2): deny with a
202        // distinguished violation rather than proceeding as if the
203        // session carried no taint. Sessionless traffic never reaches
204        // the store, so this only denies session-bearing requests.
205        let invoker = match CmfPluginInvoker::for_request(
206            Arc::clone(&manager),
207            extensions.clone(),
208            msg_payload.clone(),
209            plan,
210            Arc::clone(&self.session_store),
211        )
212        .await
213        {
214            Ok(inv) => Arc::new(inv),
215            Err(e) => {
216                tracing::error!(
217                    alarm = "session_store_failure",
218                    op = "load",
219                    route = %self.route.route_key,
220                    error = %e,
221                    "session label load failed; failing request closed"
222                );
223                return Ok(Box::new(ErasedResultFields {
224                    continue_processing: false,
225                    modified_payload: None,
226                    modified_extensions: None,
227                    violation: Some(PluginViolation::new(
228                        "session.load_failed",
229                        "session state could not be loaded",
230                    )),
231                }));
232            },
233        };
234
235        // Build the attribute bag. APL predicates read flat keys; the
236        // BagBuilder bridges typed CPEX extensions into that namespace.
237        // `route.key` lets default/policy-bundle predicates branch on
238        // which route they're attached to.
239        let post_extensions = invoker.current_extensions().await;
240        let mut bag = BagBuilder::new()
241            .with_extensions(&post_extensions)
242            .with_route_key(&self.route.route_key)
243            .build();
244
245        // Build `RoutePayload.args` from the message. Per-content shape:
246        //   * ToolCall      → arguments map (JSON Object)
247        //   * PromptRequest → arguments map (JSON Object)
248        //   * Text-only     → JSON String of concatenated text content
249        //
250        // Field pipelines operate on `args.<name>` paths. Result starts
251        // as Null on Pre (no upstream response yet); the Post phase
252        // would extract from a ToolResult / PromptResult — deferred
253        // until result-side handling lands.
254        let args_value = extract_args_from_message(&msg_payload.message);
255        let mut route_payload = match self.phase {
256            Phase::Pre => RoutePayload::new(args_value),
257            Phase::Post => {
258                // Pull the upstream result out of the message so APL
259                // `result.<field>` predicates and the `result:`
260                // pipeline have something to operate on. Falls back to
261                // `Value::Null` when the message has no ToolResult /
262                // PromptResult / Resource content (e.g. for hooks that
263                // fire on entities without a structured result).
264                let result_value = extract_result_from_message(&msg_payload.message);
265                RoutePayload::with_result(args_value, result_value)
266            },
267        };
268
269        // Flatten the call args into the bag under `args.<path>`. APL's
270        // own args pipelines read from `route_payload.args` directly,
271        // but PDP steps and predicates that reference `${args.X}` /
272        // `args.X` resolve through the bag. Mirroring the args here
273        // makes both consumers see the same vocabulary the
274        // `MessageView` exposes. (Bag-mutation via redact during the
275        // args pipeline isn't reflected back into the bag; that's fine
276        // — args predicates today read from `route_payload.args`, and
277        // the cedar substitution snapshots the pre-args view, which is
278        // what an author writing `cedar:(resource.id: ${args.X})` would
279        // expect.)
280        extract_args(&route_payload.args, &mut bag);
281        // Post phase: also project the upstream result into the bag
282        // under `result.<path>`. This is what enables predicates like
283        // `redact(result.ssn) when !perm.view_ssn` and `require(...)`
284        // gates that branch on the result. Pre phases skip this — the
285        // result is `None` by construction.
286        if matches!(self.phase, Phase::Post) {
287            if let Some(result_value) = route_payload.result.as_ref() {
288                extract_result(result_value, &mut bag);
289            }
290        }
291
292        // Slice B: real delegation invoker, sharing the CMF invoker's
293        // extensions Mutex so a `delegate(...)` step's writes to
294        // raw_credentials / delegation are visible to downstream CMF
295        // plugins and to the post phase. Routes that don't declare
296        // any `Step::Delegate` won't have entries in the plan's
297        // `delegation_entries` map; if such a route accidentally hits
298        // `delegate(...)`, the invoker returns `NotFound` and the
299        // evaluator translates it via the step's `on_error`.
300        let delegations = Arc::new(DelegationPluginInvoker::new(
301            Arc::clone(&manager),
302            invoker.extensions_arc(),
303            invoker.plan_arc(),
304        ));
305
306        // Unsized coercion: `Arc<ConcreteType>` → `Arc<dyn Trait>`. The
307        // erased forms get borrowed into `evaluate_pre`/`evaluate_post`;
308        // `dispatch_parallel` can then `Arc::clone` an owned 'static
309        // reference into each branch closure.
310        let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
311        let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
312
313        let decision = match self.phase {
314            Phase::Pre => {
315                evaluate_pre(
316                    &self.route,
317                    &mut bag,
318                    &mut route_payload,
319                    &self.pdp,
320                    &invoker_dyn,
321                    &delegations_dyn,
322                )
323                .await
324            },
325            Phase::Post => {
326                evaluate_post(
327                    &self.route,
328                    &mut bag,
329                    &mut route_payload,
330                    &self.pdp,
331                    &invoker_dyn,
332                    &delegations_dyn,
333                )
334                .await
335            },
336        };
337
338        // Drain Session-scoped taints (from `taint(label, session)` /
339        // pipeline `Stage::Taint`) into `extensions.security.labels`
340        // so the existing label-diff flow inside `persist_session`
341        // picks them up. Message-scoped taints are filtered out by
342        // `apply_session_taints` — they need their own destination
343        // (see TS2). No-op when no taints emitted.
344        invoker.apply_session_taints(&decision.taints).await;
345
346        // Commit any session-scoped labels accumulated during this
347        // request. No-op when there was no session id. The result is
348        // folded into the decision below (R18) — captured here because
349        // `continue_processing`/`violation` are computed after persist.
350        let persist_result = invoker.persist_session().await;
351
352        // Surface the final mutated payload + extensions back into the
353        // PipelineResult the executor returns to the host. The host's
354        // body re-serialization picks up edits made by APL pipelines
355        // (e.g. a redact stage that rewrote args.text).
356        let final_payload = invoker.current_payload().await;
357        let final_extensions = invoker.current_extensions().await;
358
359        // Detect whether the args pipeline mutated the payload by
360        // re-extracting from the pre-eval message (msg_payload is
361        // still borrowed) and comparing against the post-eval
362        // route_payload.args. Re-extraction allocates but mirrors the
363        // surrounding pattern and avoids holding a pre-eval clone.
364        let pre_args = extract_args_from_message(&msg_payload.message);
365        // For Post phase, also detect result mutations from `result:`
366        // pipelines. Pre routes don't carry a result so this is None.
367        let pre_result = match self.phase {
368            Phase::Pre => None,
369            Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
370        };
371        let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
372            // An args pipeline (Pre) rewrote a field. Fold the new
373            // args back into a fresh MessagePayload so downstream
374            // readers (the host's body re-serializer) see the
375            // change.
376            let mut updated = final_payload.clone();
377            write_args_back_to_message(&mut updated.message, &route_payload.args);
378            Some(Box::new(updated) as Box<dyn PluginPayload>)
379        } else if matches!(self.phase, Phase::Post)
380            && pre_result
381                .as_ref()
382                .zip(route_payload.result.as_ref())
383                .map(|(prev, current)| prev != current)
384                .unwrap_or(false)
385        {
386            // A `result:` pipeline rewrote a field in the upstream
387            // response. Fold the new result back into the message
388            // so the host's response body re-serializer can write
389            // it out before forwarding downstream.
390            let mut updated = final_payload.clone();
391            if let Some(result_value) = route_payload.result.as_ref() {
392                write_result_back_to_message(&mut updated.message, result_value);
393            }
394            Some(Box::new(updated) as Box<dyn PluginPayload>)
395        } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
396        {
397            // A `policy:` plugin mutated the message directly via
398            // `modify_payload` (not through a field pipeline). Pass
399            // the invoker's view through unchanged.
400            Some(Box::new(final_payload) as Box<dyn PluginPayload>)
401        } else {
402            None
403        };
404
405        let modified_extensions = if extensions_changed(extensions, &final_extensions) {
406            Some(final_extensions.cow_copy())
407        } else {
408            None
409        };
410
411        let (mut continue_processing, mut violation) = match decision.decision {
412            Decision::Allow => (true, None),
413            Decision::Deny {
414                reason,
415                rule_source,
416            } => {
417                let code = if rule_source.is_empty() {
418                    "policy.deny".to_string()
419                } else {
420                    rule_source
421                };
422                let reason = reason.unwrap_or_else(|| "access denied".to_string());
423                (false, Some(PluginViolation::new(code, reason)))
424            },
425        };
426
427        // Append fail-closed (R18) with merge precedence:
428        //   - decision Allow + append Err → flip to Deny with a
429        //     distinguished `session.persist_failed` violation.
430        //   - decision Deny + append Err → keep the original policy
431        //     violation (preserve attribution); the request is already
432        //     denied. The append failure surfaces only as the alarm.
433        // The alarm/metric fires on every append failure regardless of
434        // decision, since the dangerous residual is a *selective*
435        // failure (append rejected while reads still succeed).
436        if let Err(e) = persist_result {
437            tracing::error!(
438                alarm = "session_store_failure",
439                op = "append",
440                route = %self.route.route_key,
441                decision_was_allow = continue_processing,
442                error = %e,
443                "session label persist failed; failing request closed"
444            );
445            if continue_processing {
446                continue_processing = false;
447                violation = Some(PluginViolation::new(
448                    "session.persist_failed",
449                    "session state could not be persisted",
450                ));
451            }
452        }
453
454        Ok(Box::new(ErasedResultFields {
455            continue_processing,
456            modified_payload,
457            modified_extensions,
458            violation,
459        }))
460    }
461
462    fn hook_type_name(&self) -> &'static str {
463        // CmfHook::NAME — kept as a literal here to avoid pulling in the
464        // HookTypeDef trait just for the constant.
465        "cmf"
466    }
467}
468
469// =====================================================================
470// Helpers
471// =====================================================================
472
473/// Rewrite the first text part of `msg` with `new_text`. If there is no
474/// text part, append one. Mirrors what `MessagePayload`'s normal
475/// modify-path does for single-view v0.
476fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
477    for part in msg.content.iter_mut() {
478        if let cpex_core::cmf::ContentPart::Text { text } = part {
479            *text = new_text.to_string();
480            return;
481        }
482    }
483    msg.content.push(cpex_core::cmf::ContentPart::Text {
484        text: new_text.to_string(),
485    });
486}
487
488/// Extract `RoutePayload.args` from a CMF message. v0 maps:
489///   * First `ContentPart::ToolCall`      → `arguments` map (Object)
490///   * First `ContentPart::PromptRequest` → `arguments` map (Object)
491///   * Else (text / no entity parts)      → JSON String of text content
492///
493/// `args.<field>` APL paths target tool / prompt arguments directly.
494/// For text-only messages we fall back to the v0 "args = whole text"
495/// shape so `args.text` predicates keep working.
496fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
497    use cpex_core::cmf::ContentPart;
498    for part in &msg.content {
499        match part {
500            ContentPart::ToolCall { content } => {
501                return Value::Object(
502                    content
503                        .arguments
504                        .iter()
505                        .map(|(k, v)| (k.clone(), v.clone()))
506                        .collect(),
507                );
508            },
509            ContentPart::PromptRequest { content } => {
510                return Value::Object(
511                    content
512                        .arguments
513                        .iter()
514                        .map(|(k, v)| (k.clone(), v.clone()))
515                        .collect(),
516                );
517            },
518            _ => {},
519        }
520    }
521    Value::String(msg.get_text_content())
522}
523
524/// Inverse of [`extract_args_from_message`]: write `args` back into
525/// `msg`'s first ToolCall / PromptRequest argument map, or — for
526/// text payloads — into the first text part.
527///
528/// Silently no-ops when the args shape doesn't match the message
529/// content shape (e.g. operator pipeline produced a String for what
530/// was originally a ToolCall). The mismatch path is recoverable —
531/// the upstream just sees the original unmodified content rather
532/// than a malformed rewrite.
533fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
534    use cpex_core::cmf::ContentPart;
535    for part in msg.content.iter_mut() {
536        match part {
537            ContentPart::ToolCall { content } => {
538                if let Some(obj) = args.as_object() {
539                    content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
540                }
541                return;
542            },
543            ContentPart::PromptRequest { content } => {
544                if let Some(obj) = args.as_object() {
545                    content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
546                }
547                return;
548            },
549            _ => {},
550        }
551    }
552    // Fall through: no structured entity part — treat as text.
553    if let Some(text) = args.as_str() {
554        rewrite_message_text(msg, text);
555    }
556}
557
558/// Extract `RoutePayload.result` from a CMF message. Mirror of
559/// [`extract_args_from_message`] for the Post phase. v0 maps:
560///   * First `ContentPart::ToolResult` → its `content` JSON value
561///   * Else (text / no structured result part) → JSON String of text
562///
563/// `result.<field>` APL paths target the structured result directly.
564fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
565    use cpex_core::cmf::ContentPart;
566    for part in &msg.content {
567        if let ContentPart::ToolResult { content } = part {
568            return content.content.clone();
569        }
570    }
571    Value::String(msg.get_text_content())
572}
573
574/// Inverse of [`extract_result_from_message`]: write a mutated
575/// `result` back into the message's first `ContentPart::ToolResult.content`,
576/// or — for text-only messages — into the first text part. The praxis
577/// filter's response-body re-serializer then lifts the new content
578/// out of the ContentPart and folds it back into the JSON-RPC
579/// `result.content[*].text` payload.
580fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
581    use cpex_core::cmf::ContentPart;
582    for part in msg.content.iter_mut() {
583        if let ContentPart::ToolResult { content } = part {
584            content.content = result.clone();
585            return;
586        }
587    }
588    if let Some(text) = result.as_str() {
589        rewrite_message_text(msg, text);
590    }
591}
592
593/// Cheap pointer-equality check across the few mutable extension slots
594/// the executor would care about. False positives (claiming a change
595/// when there isn't one) are cheap — the executor re-validates anyway.
596fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
597    let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
598        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
599        (None, None) => false,
600        _ => true,
601    };
602    let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
603        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
604        (None, None) => false,
605        _ => true,
606    };
607    // `delegate(...)` steps write minted tokens into
608    // `raw_credentials.delegated_tokens` via the shared Mutex —
609    // without this check, a route whose only Extensions mutation is
610    // a delegate (no security / delegation chain edit) looks
611    // unchanged, so the executor never merges the minted token back
612    // and downstream readers (our HttpFilter attaching the token to
613    // the upstream request) see nothing.
614    let raw_creds_changed = match (
615        before.raw_credentials.as_ref(),
616        after.raw_credentials.as_ref(),
617    ) {
618        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
619        (None, None) => false,
620        _ => true,
621    };
622    security_changed || delegation_changed || raw_creds_changed
623}