praxis_policy_apl_runtime/
parallel_safety.rs1use praxis_policy_apl_core::rules::{CompiledRoute, Effect};
41use praxis_policy_core::engine::PolicyEngine;
42use praxis_policy_core::plugin::PluginMode;
43
44pub trait PluginModeLookup {
54 fn mode_for(&self, name: &str) -> Option<PluginMode>;
57}
58
59impl PluginModeLookup for PolicyEngine {
60 fn mode_for(&self, name: &str) -> Option<PluginMode> {
61 self.get_plugin(name).map(|p| p.mode())
62 }
63}
64
65pub fn validate_parallel_plugin_modes<L: PluginModeLookup + ?Sized>(
81 route: &CompiledRoute,
82 registry: &L,
83) -> Result<(), String> {
84 let mut errors: Vec<String> = Vec::new();
85 for (phase_name, effects) in [
86 ("pre_invocation", route.policy.as_slice()),
87 ("post_invocation", route.post_policy.as_slice()),
88 ] {
89 for (idx, effect) in effects.iter().enumerate() {
90 walk_effect(
91 effect,
92 &format!("routes.{}.{}[{}]", route.route_key, phase_name, idx),
93 false,
94 registry,
95 &mut errors,
96 );
97 }
98 }
99 if errors.is_empty() {
100 Ok(())
101 } else {
102 Err(errors.join("; "))
103 }
104}
105
106fn walk_effect<L: PluginModeLookup + ?Sized>(
112 effect: &Effect,
113 location: &str,
114 under_parallel: bool,
115 registry: &L,
116 errors: &mut Vec<String>,
117) {
118 match effect {
119 Effect::Plugin { name } if under_parallel => {
120 check_plugin_mode(name, location, registry, errors);
121 },
122 Effect::Parallel(inner) => {
123 for e in inner {
124 walk_effect(e, location, true, registry, errors);
125 }
126 },
127 Effect::Sequential(inner) => {
128 for e in inner {
129 walk_effect(e, location, under_parallel, registry, errors);
130 }
131 },
132 Effect::When { body, .. } => {
133 for e in body {
137 walk_effect(e, location, under_parallel, registry, errors);
138 }
139 },
140 Effect::Pdp {
141 on_allow, on_deny, ..
142 } => {
143 for e in on_allow.iter().chain(on_deny.iter()) {
144 walk_effect(e, location, under_parallel, registry, errors);
145 }
146 },
147 _ => {},
153 }
154}
155
156fn check_plugin_mode<L: PluginModeLookup + ?Sized>(
157 name: &str,
158 location: &str,
159 registry: &L,
160 errors: &mut Vec<String>,
161) {
162 let mode = if let Some(m) = registry.mode_for(name) {
163 m
164 } else {
165 errors.push(format!(
166 "{location}: `parallel:` references unknown plugin `{name}`"
167 ));
168 return;
169 };
170 if !is_safe_in_parallel(mode) {
171 errors.push(format!(
172 "{location}: plugin `{name}` has mode `{mode}` which can modify state; parallel \
173 branches discard mutations, so this would silently lose its effect. \
174 Use `sequential:` for ordered mutations or change the plugin's mode.",
175 ));
176 }
177}
178
179fn is_safe_in_parallel(mode: PluginMode) -> bool {
182 matches!(
183 mode,
184 PluginMode::Audit
185 | PluginMode::Concurrent
186 | PluginMode::FireAndForget
187 | PluginMode::Disabled
188 )
189}
190
191#[cfg(test)]
192#[allow(
193 clippy::expect_used,
194 clippy::indexing_slicing,
195 clippy::panic,
196 clippy::print_stderr,
197 clippy::print_stdout,
198 clippy::unwrap_used,
199 reason = "tests"
200)]
201mod tests {
202 use super::*;
203 use praxis_policy_apl_core::rules::Expression;
204 use std::collections::HashMap;
205
206 struct MockLookup(HashMap<String, PluginMode>);
210
211 impl MockLookup {
212 fn new() -> Self {
213 Self(HashMap::new())
214 }
215 fn with(mut self, name: &str, mode: PluginMode) -> Self {
216 self.0.insert(name.to_owned(), mode);
217 self
218 }
219 }
220
221 impl PluginModeLookup for MockLookup {
222 fn mode_for(&self, name: &str) -> Option<PluginMode> {
223 self.0.get(name).copied()
224 }
225 }
226
227 fn route_with_policy(effects: Vec<Effect>) -> CompiledRoute {
228 let mut r = CompiledRoute::new("test_route");
229 r.policy = effects;
230 r
231 }
232
233 fn rule(effects: Vec<Effect>) -> Effect {
234 Effect::When {
235 condition: Expression::Always,
236 body: effects,
237 source: "test".into(),
238 }
239 }
240
241 fn parallel_plugin(name: &str) -> Effect {
242 Effect::Parallel(vec![Effect::Plugin { name: name.into() }])
243 }
244
245 #[test]
246 fn audit_plugin_in_parallel_is_accepted() {
247 let reg = MockLookup::new().with("audit_logger", PluginMode::Audit);
248 let route = route_with_policy(vec![rule(vec![parallel_plugin("audit_logger")])]);
249 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
250 }
251
252 #[test]
253 fn concurrent_plugin_in_parallel_is_accepted() {
254 let reg = MockLookup::new().with("pii_scanner", PluginMode::Concurrent);
255 let route = route_with_policy(vec![rule(vec![parallel_plugin("pii_scanner")])]);
256 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
257 }
258
259 #[test]
260 fn fire_and_forget_in_parallel_is_accepted() {
261 let reg = MockLookup::new().with("metrics", PluginMode::FireAndForget);
262 let route = route_with_policy(vec![rule(vec![parallel_plugin("metrics")])]);
263 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
264 }
265
266 #[test]
267 fn sequential_plugin_in_parallel_is_rejected() {
268 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
269 let route = route_with_policy(vec![rule(vec![parallel_plugin("mutator")])]);
270 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
271 assert!(err.contains("mutator"), "names plugin: {err}");
272 assert!(err.contains("sequential"), "names mode: {err}");
273 assert!(err.contains("`sequential:`"), "suggests fix: {err}");
274 }
275
276 #[test]
277 fn transform_plugin_in_parallel_is_rejected() {
278 let reg = MockLookup::new().with("redactor", PluginMode::Transform);
279 let route = route_with_policy(vec![rule(vec![parallel_plugin("redactor")])]);
280 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
281 assert!(err.contains("transform"));
282 }
283
284 #[test]
285 fn unknown_plugin_in_parallel_is_rejected() {
286 let reg = MockLookup::new();
287 let route = route_with_policy(vec![rule(vec![parallel_plugin("ghost")])]);
288 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
289 assert!(err.contains("unknown plugin"));
290 assert!(err.contains("ghost"));
291 }
292
293 #[test]
294 fn sequential_plugin_outside_parallel_is_allowed() {
295 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
298 let route = route_with_policy(vec![rule(vec![Effect::Plugin {
299 name: "mutator".into(),
300 }])]);
301 assert!(validate_parallel_plugin_modes(&route, ®).is_ok());
302 }
303
304 #[test]
305 fn nested_sequential_inside_parallel_still_validates_plugins() {
306 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
310 let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
311 Effect::Sequential(vec![Effect::Plugin {
312 name: "mutator".into(),
313 }]),
314 ])])]);
315 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
316 assert!(err.contains("mutator"));
317 }
318
319 #[test]
320 fn multiple_violations_all_reported() {
321 let reg = MockLookup::new()
324 .with("a", PluginMode::Sequential)
325 .with("b", PluginMode::Transform);
326 let route = route_with_policy(vec![rule(vec![Effect::Parallel(vec![
327 Effect::Plugin { name: "a".into() },
328 Effect::Plugin { name: "b".into() },
329 ])])]);
330 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
331 assert!(err.contains("`a`"), "names a: {err}");
332 assert!(err.contains("`b`"), "names b: {err}");
333 }
334
335 #[test]
336 fn post_policy_phase_is_validated_too() {
337 let reg = MockLookup::new().with("mutator", PluginMode::Sequential);
338 let mut route = CompiledRoute::new("test_route");
339 route.post_policy = vec![rule(vec![parallel_plugin("mutator")])];
340 let err = validate_parallel_plugin_modes(&route, ®).unwrap_err();
341 assert!(err.contains("post_invocation"));
342 }
343}