Skip to main content

apl_cpex/
pdp_router.rs

1// Location: ./crates/apl-cpex/src/pdp_router.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `PdpRouter` — composite `PdpResolver` that dispatches each call to the
7// resolver matching the requested `PdpDialect`. Lets a single host (or a
8// single `AplRouteHandler`) carry resolvers for several backends at the
9// same time without having to pick one at construction.
10//
11// The PDP backends that ship in this workspace, each its own crate
12// registered here by dialect:
13//
14//   - **cedar** (`cpex-pdp-cedar-direct`) — in-process Cedar policy-set
15//     evaluation.
16//   - **opa** — Open Policy Agent / Rego.
17//   - **authzen** — AuthZen-protocol external decision point.
18//   - **nemo** — NeMo reasoning backend.
19//   - **cel** (`cpex-pdp-cel`) — inline CEL boolean predicates authored in
20//     the route YAML (`cel: { expr: "..." }`); smallest dep tree, no
21//     external policy store.
22//
23// Routing is by dialect equality. The first registered resolver for a
24// given dialect wins on duplicate registration — registering Cedar twice
25// keeps the original and logs a warning. Unknown-dialect calls return
26// `PdpError::NoResolver(dialect)`.
27//
28// `PdpRouter` is itself a `PdpResolver`, so it slots straight into
29// `AplRouteHandler::with_pdp`. Its own `dialect()` method returns
30// `PdpDialect::Custom("router")` — a sentinel the evaluator doesn't
31// branch on; only inner resolvers' dialects matter.
32
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use async_trait::async_trait;
37
38use apl_core::attributes::AttributeBag;
39use apl_core::step::{PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver};
40
41/// Dispatches PDP calls to the right resolver based on
42/// `Step::Pdp.call.dialect`. Construct with `new()`, add resolvers via
43/// `register`, then hand the router to a route handler.
44///
45/// Cloning is cheap (refcount bumps on each resolver `Arc`) — the
46/// `AplConfigVisitor` snapshots its accumulated router into an `Arc`
47/// for every installed route handler so a config reload that mutates
48/// the visitor state doesn't tear in-flight handlers.
49#[derive(Clone)]
50pub struct PdpRouter {
51    resolvers: HashMap<PdpDialect, Arc<dyn PdpResolver>>,
52}
53
54impl PdpRouter {
55    pub fn new() -> Self {
56        Self {
57            resolvers: HashMap::new(),
58        }
59    }
60
61    /// Register a resolver for its declared dialect. If a resolver is
62    /// already registered for that dialect the new one is dropped and a
63    /// warning is logged — explicit replacement should go through
64    /// `replace` instead so the intent is visible at call sites.
65    pub fn register(&mut self, resolver: Arc<dyn PdpResolver>) -> &mut Self {
66        let dialect = resolver.dialect();
67        if self.resolvers.contains_key(&dialect) {
68            tracing::warn!(
69                dialect = ?dialect,
70                "PdpRouter: resolver for dialect already registered — keeping existing",
71            );
72            return self;
73        }
74        self.resolvers.insert(dialect, resolver);
75        self
76    }
77
78    /// Replace any existing resolver for the new resolver's dialect.
79    /// Use this when the host genuinely wants to swap in a different
80    /// implementation (testing, A/B rollout).
81    pub fn replace(&mut self, resolver: Arc<dyn PdpResolver>) -> &mut Self {
82        let dialect = resolver.dialect();
83        self.resolvers.insert(dialect, resolver);
84        self
85    }
86
87    /// Number of registered resolvers. Useful for tests.
88    pub fn len(&self) -> usize {
89        self.resolvers.len()
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.resolvers.is_empty()
94    }
95}
96
97impl Default for PdpRouter {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103#[async_trait]
104impl PdpResolver for PdpRouter {
105    fn dialect(&self) -> PdpDialect {
106        // Sentinel — evaluator routes per `Step::Pdp.call.dialect`, not
107        // the resolver's own declared dialect. The router never claims to
108        // be one of the real dialects so a stray equality check can't
109        // accidentally pick it.
110        PdpDialect::Custom("router".to_string())
111    }
112
113    async fn evaluate(&self, call: &PdpCall, bag: &AttributeBag) -> Result<PdpDecision, PdpError> {
114        let resolver = self
115            .resolvers
116            .get(&call.dialect)
117            .ok_or_else(|| PdpError::NoResolver(call.dialect.clone()))?;
118        resolver.evaluate(call, bag).await
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use apl_core::evaluator::Decision;
126
127    struct FakePdp {
128        dialect: PdpDialect,
129        decision: Decision,
130    }
131
132    #[async_trait]
133    impl PdpResolver for FakePdp {
134        fn dialect(&self) -> PdpDialect {
135            self.dialect.clone()
136        }
137
138        async fn evaluate(
139            &self,
140            _call: &PdpCall,
141            _bag: &AttributeBag,
142        ) -> Result<PdpDecision, PdpError> {
143            Ok(PdpDecision {
144                decision: self.decision.clone(),
145                diagnostics: Vec::new(),
146            })
147        }
148    }
149
150    #[tokio::test]
151    async fn routes_by_dialect() {
152        let mut router = PdpRouter::new();
153        router.register(Arc::new(FakePdp {
154            dialect: PdpDialect::Cedar,
155            decision: Decision::Allow,
156        }));
157        router.register(Arc::new(FakePdp {
158            dialect: PdpDialect::Opa,
159            decision: Decision::Deny {
160                reason: Some("opa says no".into()),
161                rule_source: "opa".into(),
162            },
163        }));
164
165        let bag = AttributeBag::default();
166        let cedar_call = PdpCall {
167            dialect: PdpDialect::Cedar,
168            args: serde_yaml::Value::Null,
169        };
170        let opa_call = PdpCall {
171            dialect: PdpDialect::Opa,
172            args: serde_yaml::Value::Null,
173        };
174
175        let cedar_res = router.evaluate(&cedar_call, &bag).await.unwrap();
176        assert!(matches!(cedar_res.decision, Decision::Allow));
177
178        let opa_res = router.evaluate(&opa_call, &bag).await.unwrap();
179        assert!(matches!(opa_res.decision, Decision::Deny { .. }));
180    }
181
182    #[tokio::test]
183    async fn missing_dialect_returns_no_resolver() {
184        let router = PdpRouter::new();
185        let bag = AttributeBag::default();
186        let call = PdpCall {
187            dialect: PdpDialect::Cedar,
188            args: serde_yaml::Value::Null,
189        };
190        let err = router.evaluate(&call, &bag).await.unwrap_err();
191        assert!(matches!(err, PdpError::NoResolver(_)));
192    }
193
194    #[tokio::test]
195    async fn duplicate_register_keeps_first() {
196        let mut router = PdpRouter::new();
197        router.register(Arc::new(FakePdp {
198            dialect: PdpDialect::Cedar,
199            decision: Decision::Allow,
200        }));
201        router.register(Arc::new(FakePdp {
202            dialect: PdpDialect::Cedar,
203            decision: Decision::Deny {
204                reason: Some("shouldn't fire".into()),
205                rule_source: "test".into(),
206            },
207        }));
208        let call = PdpCall {
209            dialect: PdpDialect::Cedar,
210            args: serde_yaml::Value::Null,
211        };
212        let res = router
213            .evaluate(&call, &AttributeBag::default())
214            .await
215            .unwrap();
216        assert!(matches!(res.decision, Decision::Allow));
217    }
218}