1use std::sync::Arc;
28
29use crate::attributes::AttributeBag;
30use crate::evaluator::{evaluate_effects, evaluate_pipeline, Decision, FieldOutcome};
31use crate::pipeline::TaintEvent;
32use crate::rules::CompiledRoute;
33use crate::step::{
34 DelegationInvoker, DispatchPhase, ElicitationInvoker, PdpResolver, PluginInvoker,
35};
36
37#[derive(Debug, Clone)]
41pub struct RoutePayload {
42 pub args: serde_json::Value,
43 pub result: Option<serde_json::Value>,
44}
45
46impl RoutePayload {
47 pub fn new(args: serde_json::Value) -> Self {
48 Self { args, result: None }
49 }
50
51 pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self {
52 Self {
53 args,
54 result: Some(result),
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct RouteDecision {
62 pub decision: Decision,
63 pub taints: Vec<TaintEvent>,
65 pub args_modified: bool,
67 pub result_modified: bool,
69 pub pending: Option<crate::step::PendingElicitation>,
75}
76
77pub async fn evaluate_pre(
88 route: &CompiledRoute,
89 bag: &mut AttributeBag,
90 payload: &mut RoutePayload,
91 pdp: &Arc<dyn PdpResolver>,
92 plugins: &Arc<dyn PluginInvoker>,
93 delegations: &Arc<dyn DelegationInvoker>,
94 elicitations: &Arc<dyn ElicitationInvoker>,
95) -> RouteDecision {
96 let mut taints: Vec<TaintEvent> = Vec::new();
97 let mut args_modified = false;
98
99 for rule in &route.args {
101 let Some(current) = get_dotted(&payload.args, &rule.field).cloned() else {
102 continue; };
104 let eval = evaluate_pipeline(
105 &rule.pipeline,
106 ¤t,
107 bag,
108 plugins,
109 &rule.field,
110 DispatchPhase::Pre,
111 )
112 .await;
113 taints.extend(eval.taints);
114 match eval.outcome {
115 FieldOutcome::Pass => {},
116 FieldOutcome::Replace(new_val) => {
117 if set_dotted(&mut payload.args, &rule.field, new_val) {
118 args_modified = true;
119 }
120 },
121 FieldOutcome::Omit => {
122 if remove_dotted(&mut payload.args, &rule.field) {
123 args_modified = true;
124 }
125 },
126 FieldOutcome::Deny {
127 reason,
128 stage_index: _,
129 } => {
130 return RouteDecision {
131 decision: Decision::Deny {
132 reason: Some(reason),
133 rule_source: rule.source.clone(),
134 },
135 taints,
136 args_modified,
137 result_modified: false,
138 pending: None,
139 };
140 },
141 }
142 }
143
144 let policy_eval = evaluate_effects(
146 &route.policy,
147 bag,
148 pdp,
149 plugins,
150 delegations,
151 elicitations,
152 DispatchPhase::Pre,
153 payload,
154 )
155 .await;
156 args_modified |= policy_eval.args_modified;
159 taints.extend(policy_eval.taints);
160 RouteDecision {
161 decision: policy_eval.decision,
162 taints,
163 args_modified,
164 result_modified: false,
165 pending: policy_eval.pending,
166 }
167}
168
169pub async fn evaluate_post(
177 route: &CompiledRoute,
178 bag: &mut AttributeBag,
179 payload: &mut RoutePayload,
180 pdp: &Arc<dyn PdpResolver>,
181 plugins: &Arc<dyn PluginInvoker>,
182 delegations: &Arc<dyn DelegationInvoker>,
183 elicitations: &Arc<dyn ElicitationInvoker>,
184) -> RouteDecision {
185 let mut taints: Vec<TaintEvent> = Vec::new();
186 let mut result_modified = false;
187
188 if let Some(result) = payload.result.as_mut() {
190 for rule in &route.result {
191 let Some(current) = get_dotted(result, &rule.field).cloned() else {
192 continue;
193 };
194 let eval = evaluate_pipeline(
195 &rule.pipeline,
196 ¤t,
197 bag,
198 plugins,
199 &rule.field,
200 DispatchPhase::Post,
201 )
202 .await;
203 taints.extend(eval.taints);
204 match eval.outcome {
205 FieldOutcome::Pass => {},
206 FieldOutcome::Replace(new_val) => {
207 if set_dotted(result, &rule.field, new_val) {
208 result_modified = true;
209 }
210 },
211 FieldOutcome::Omit => {
212 if remove_dotted(result, &rule.field) {
213 result_modified = true;
214 }
215 },
216 FieldOutcome::Deny {
217 reason,
218 stage_index: _,
219 } => {
220 return RouteDecision {
221 decision: Decision::Deny {
222 reason: Some(reason),
223 rule_source: rule.source.clone(),
224 },
225 taints,
226 args_modified: false,
227 result_modified,
228 pending: None,
229 };
230 },
231 }
232 }
233 }
234
235 let post_eval = evaluate_effects(
237 &route.post_policy,
238 bag,
239 pdp,
240 plugins,
241 delegations,
242 elicitations,
243 DispatchPhase::Post,
244 payload,
245 )
246 .await;
247 result_modified |= post_eval.result_modified;
250 taints.extend(post_eval.taints);
251
252 RouteDecision {
253 decision: post_eval.decision,
254 taints,
255 args_modified: false,
256 result_modified,
257 pending: post_eval.pending,
258 }
259}
260
261pub async fn evaluate_route(
272 route: &CompiledRoute,
273 bag: &mut AttributeBag,
274 payload: &mut RoutePayload,
275 pdp: &Arc<dyn PdpResolver>,
276 plugins: &Arc<dyn PluginInvoker>,
277 delegations: &Arc<dyn DelegationInvoker>,
278 elicitations: &Arc<dyn ElicitationInvoker>,
279) -> RouteDecision {
280 let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations, elicitations).await;
281 if matches!(pre.decision, Decision::Deny { .. }) || pre.pending.is_some() {
286 return pre;
287 }
288 let post = evaluate_post(route, bag, payload, pdp, plugins, delegations, elicitations).await;
289 let mut taints = pre.taints;
290 taints.extend(post.taints);
291 RouteDecision {
292 decision: post.decision,
293 taints,
294 args_modified: pre.args_modified,
295 result_modified: post.result_modified,
296 pending: post.pending,
297 }
298}
299
300pub(crate) fn get_dotted<'a>(
307 root: &'a serde_json::Value,
308 path: &str,
309) -> Option<&'a serde_json::Value> {
310 let mut cur = root;
311 for seg in path.split('.') {
312 cur = cur.get(seg)?;
313 }
314 Some(cur)
315}
316
317pub(crate) fn set_dotted(
321 root: &mut serde_json::Value,
322 path: &str,
323 value: serde_json::Value,
324) -> bool {
325 let parts: Vec<&str> = path.split('.').collect();
326 let (leaf, parents) = match parts.split_last() {
327 Some(x) => x,
328 None => return false,
329 };
330 let mut cur = root;
331 for seg in parents {
332 let Some(next) = cur.get_mut(*seg) else {
333 return false;
334 };
335 if !next.is_object() {
336 return false;
337 }
338 cur = next;
339 }
340 if let serde_json::Value::Object(map) = cur {
341 map.insert((*leaf).to_string(), value);
342 true
343 } else {
344 false
345 }
346}
347
348pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool {
350 let parts: Vec<&str> = path.split('.').collect();
351 let (leaf, parents) = match parts.split_last() {
352 Some(x) => x,
353 None => return false,
354 };
355 let mut cur = root;
356 for seg in parents {
357 let Some(next) = cur.get_mut(*seg) else {
358 return false;
359 };
360 if !next.is_object() {
361 return false;
362 }
363 cur = next;
364 }
365 if let serde_json::Value::Object(map) = cur {
366 map.remove(*leaf).is_some()
367 } else {
368 false
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck};
376 use crate::rules::{Effect, Expression, Rule};
377 use crate::step::{
378 NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError,
379 PluginError, PluginInvocation, PluginOutcome,
380 };
381 use async_trait::async_trait;
382 use serde_json::json;
383
384 struct AllowPdp;
387 #[async_trait]
388 impl PdpResolver for AllowPdp {
389 fn dialect(&self) -> PdpDialect {
390 PdpDialect::Cedar
391 }
392 async fn evaluate(
393 &self,
394 _call: &PdpCall,
395 _bag: &AttributeBag,
396 ) -> Result<PdpDecision, PdpError> {
397 Ok(PdpDecision {
398 decision: Decision::Allow,
399 diagnostics: vec![],
400 })
401 }
402 }
403
404 struct NoPlugins;
405 #[async_trait]
406 impl PluginInvoker for NoPlugins {
407 async fn invoke(
408 &self,
409 name: &str,
410 _bag: &AttributeBag,
411 _invocation: PluginInvocation<'_>,
412 ) -> Result<PluginOutcome, PluginError> {
413 Err(PluginError::NotFound(name.into()))
414 }
415 }
416
417 struct PendingElicitor;
420 #[async_trait]
421 impl ElicitationInvoker for PendingElicitor {
422 async fn dispatch(
423 &self,
424 _step: &crate::step::ElicitStep,
425 _resolved_from: &str,
426 ) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
427 Ok(crate::step::ElicitationDispatch {
428 id: "elic-route-1".into(),
429 approver: None,
430 intent_id: None,
431 expires_at: None,
432 })
433 }
434 async fn check(
435 &self,
436 _step: &crate::step::ElicitStep,
437 _id: &str,
438 ) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
439 Ok(crate::step::ElicitationStatus::Pending)
440 }
441 async fn validate(
442 &self,
443 _step: &crate::step::ElicitStep,
444 _id: &str,
445 ) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
446 unreachable!("validate must not run while pending")
447 }
448 }
449
450 fn pdp_arc() -> Arc<dyn PdpResolver> {
454 Arc::new(AllowPdp)
455 }
456 fn plugins() -> Arc<dyn PluginInvoker> {
457 Arc::new(NoPlugins)
458 }
459 fn delegations() -> Arc<dyn DelegationInvoker> {
460 Arc::new(NoopDelegationInvoker)
461 }
462 fn elicitations() -> Arc<dyn ElicitationInvoker> {
463 Arc::new(NoopElicitationInvoker)
464 }
465
466 fn field_rule(field: &str, stages: Vec<Stage>) -> FieldRule {
467 FieldRule {
468 field: field.into(),
469 pipeline: Pipeline { stages },
470 source: format!("test.{}", field),
471 }
472 }
473
474 fn deny_rule(source: &str, reason: &str) -> Rule {
475 Rule::single(
476 Expression::Always,
477 Effect::Deny {
478 reason: Some(reason.into()),
479 code: None,
480 },
481 source,
482 )
483 }
484
485 #[tokio::test]
488 async fn empty_route_allows() {
489 let route = CompiledRoute::new("noop");
490 let mut bag = AttributeBag::new();
491 let mut payload = RoutePayload::new(json!({}));
492 let r = evaluate_route(
493 &route,
494 &mut bag,
495 &mut payload,
496 &pdp_arc(),
497 &plugins(),
498 &delegations(),
499 &elicitations(),
500 )
501 .await;
502 assert_eq!(r.decision, Decision::Allow);
503 assert!(!r.args_modified);
504 assert!(!r.result_modified);
505 assert!(r.taints.is_empty());
506 }
507
508 #[tokio::test]
509 async fn pending_elicitation_suspends_route_and_skips_post() {
510 let mut route = CompiledRoute::new("payroll");
514 route.policy.push(Effect::Elicit(crate::step::ElicitStep {
515 kind: crate::step::ElicitKind::Approval,
516 plugin_name: "manager-approver".into(),
517 channel: Some("ciba".into()),
518 from: "user.manager".into(),
519 purpose: None,
520 scope: None,
521 timeout: None,
522 config_override: None,
523 on_error: None,
524 source: "payroll.policy[0]".into(),
525 }));
526 route
528 .result
529 .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }]));
530
531 let elicitor: Arc<dyn ElicitationInvoker> = Arc::new(PendingElicitor);
532 let mut bag = AttributeBag::new();
533 bag.set("user.manager", "manager@corp.com");
536 let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789" }));
537 let r = evaluate_route(
538 &route,
539 &mut bag,
540 &mut payload,
541 &pdp_arc(),
542 &plugins(),
543 &delegations(),
544 &elicitor,
545 )
546 .await;
547
548 assert_eq!(r.decision, Decision::Allow, "pending is not a deny");
549 let bundle = r.pending.expect("route surfaced the pending bundle");
550 assert_eq!(bundle.id, "elic-route-1");
551 assert_eq!(bundle.plugin_name, "manager-approver");
552 assert!(!r.result_modified);
554 assert_eq!(
555 payload.result.as_ref().unwrap()["ssn"],
556 json!("123-45-6789")
557 );
558 }
559
560 #[tokio::test]
561 async fn args_pipeline_mutates_payload() {
562 let mut route = CompiledRoute::new("ping");
563 route
564 .args
565 .push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }]));
566 let mut bag = AttributeBag::new();
567 let mut payload = RoutePayload::new(json!({ "ssn": "123-45-6789" }));
568 let r = evaluate_route(
569 &route,
570 &mut bag,
571 &mut payload,
572 &pdp_arc(),
573 &plugins(),
574 &delegations(),
575 &elicitations(),
576 )
577 .await;
578 assert_eq!(r.decision, Decision::Allow);
579 assert!(r.args_modified);
580 assert_eq!(payload.args["ssn"], json!("*******6789"));
581 }
582
583 #[tokio::test]
584 async fn args_deny_halts_route() {
585 let mut route = CompiledRoute::new("ping");
586 route.args.push(field_rule(
587 "amount",
588 vec![
589 Stage::Type(TypeCheck::Int),
590 Stage::Range {
591 min: Some(0),
592 max: Some(100),
593 },
594 ],
595 ));
596 route
600 .policy
601 .push(Effect::from(deny_rule("policy[0]", "policy denied too")));
602
603 let mut bag = AttributeBag::new();
604 let mut payload = RoutePayload::new(json!({ "amount": 200 }));
605 let r = evaluate_route(
606 &route,
607 &mut bag,
608 &mut payload,
609 &pdp_arc(),
610 &plugins(),
611 &delegations(),
612 &elicitations(),
613 )
614 .await;
615 match r.decision {
616 Decision::Deny { rule_source, .. } => {
617 assert!(
618 rule_source.contains("amount"),
619 "expected args rule source, got {}",
620 rule_source
621 );
622 },
623 d => panic!("expected Deny from args phase, got {:?}", d),
624 }
625 }
626
627 #[tokio::test]
628 async fn args_missing_field_is_skipped() {
629 let mut route = CompiledRoute::new("ping");
632 route.args.push(field_rule(
633 "compensation",
634 vec![Stage::Type(TypeCheck::Int)],
635 ));
636 let mut bag = AttributeBag::new();
637 let mut payload = RoutePayload::new(json!({ "other_field": 5 }));
638 let r = evaluate_route(
639 &route,
640 &mut bag,
641 &mut payload,
642 &pdp_arc(),
643 &plugins(),
644 &delegations(),
645 &elicitations(),
646 )
647 .await;
648 assert_eq!(r.decision, Decision::Allow);
649 assert!(!r.args_modified);
650 }
651
652 #[tokio::test]
653 async fn args_omit_drops_field() {
654 let mut route = CompiledRoute::new("ping");
655 route.args.push(field_rule("secret", vec![Stage::Omit]));
656 let mut bag = AttributeBag::new();
657 let mut payload = RoutePayload::new(json!({ "secret": "xyz", "keep": 1 }));
658 let r = evaluate_route(
659 &route,
660 &mut bag,
661 &mut payload,
662 &pdp_arc(),
663 &plugins(),
664 &delegations(),
665 &elicitations(),
666 )
667 .await;
668 assert_eq!(r.decision, Decision::Allow);
669 assert!(r.args_modified);
670 assert!(payload.args.get("secret").is_none());
671 assert_eq!(payload.args["keep"], json!(1));
672 }
673
674 #[tokio::test]
675 async fn policy_deny_halts_before_result() {
676 let mut route = CompiledRoute::new("ping");
677 route
678 .policy
679 .push(Effect::from(deny_rule("policy[0]", "blocked")));
680 route
682 .result
683 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
684
685 let mut bag = AttributeBag::new();
686 let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" }));
687 let r = evaluate_route(
688 &route,
689 &mut bag,
690 &mut payload,
691 &pdp_arc(),
692 &plugins(),
693 &delegations(),
694 &elicitations(),
695 )
696 .await;
697 match r.decision {
698 Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"),
699 d => panic!("expected policy deny, got {:?}", d),
700 }
701 assert!(!r.result_modified);
702 assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("123"));
704 }
705
706 #[tokio::test]
707 async fn result_phase_skipped_when_no_response() {
708 let mut route = CompiledRoute::new("ping");
709 route
710 .result
711 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
712 let mut bag = AttributeBag::new();
713 let mut payload = RoutePayload::new(json!({})); let r = evaluate_route(
715 &route,
716 &mut bag,
717 &mut payload,
718 &pdp_arc(),
719 &plugins(),
720 &delegations(),
721 &elicitations(),
722 )
723 .await;
724 assert_eq!(r.decision, Decision::Allow);
725 assert!(!r.result_modified);
726 }
727
728 #[tokio::test]
729 async fn result_pipeline_redacts_field() {
730 let mut route = CompiledRoute::new("ping");
731 route
732 .result
733 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
734 let mut bag = AttributeBag::new();
735 let mut payload =
736 RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789", "name": "alice" }));
737 let r = evaluate_route(
738 &route,
739 &mut bag,
740 &mut payload,
741 &pdp_arc(),
742 &plugins(),
743 &delegations(),
744 &elicitations(),
745 )
746 .await;
747 assert_eq!(r.decision, Decision::Allow);
748 assert!(r.result_modified);
749 let result = payload.result.as_ref().unwrap();
750 assert_eq!(result["ssn"], json!("[REDACTED]"));
751 assert_eq!(result["name"], json!("alice"));
752 }
753
754 #[tokio::test]
755 async fn taints_accumulate_across_phases() {
756 let mut route = CompiledRoute::new("ping");
757 route.args.push(field_rule(
759 "input",
760 vec![Stage::Taint {
761 label: "args_seen".into(),
762 scopes: vec![TaintScope::Session],
763 }],
764 ));
765 route.result.push(field_rule(
767 "output",
768 vec![Stage::Taint {
769 label: "result_seen".into(),
770 scopes: vec![TaintScope::Message],
771 }],
772 ));
773 let mut bag = AttributeBag::new();
774 let mut payload =
775 RoutePayload::with_result(json!({ "input": "hello" }), json!({ "output": "world" }));
776 let r = evaluate_route(
777 &route,
778 &mut bag,
779 &mut payload,
780 &pdp_arc(),
781 &plugins(),
782 &delegations(),
783 &elicitations(),
784 )
785 .await;
786 assert_eq!(r.decision, Decision::Allow);
787 let labels: Vec<&str> = r.taints.iter().map(|t| t.label.as_str()).collect();
788 assert_eq!(labels, vec!["args_seen", "result_seen"]);
789 }
790
791 #[tokio::test]
792 async fn nested_field_path_resolves_and_writes() {
793 let mut route = CompiledRoute::new("ping");
794 route.args.push(field_rule(
795 "user.profile.ssn",
796 vec![Stage::Mask { keep_last: 4 }],
797 ));
798 let mut bag = AttributeBag::new();
799 let mut payload = RoutePayload::new(json!({
800 "user": { "profile": { "ssn": "123-45-6789", "name": "alice" } }
801 }));
802 let r = evaluate_route(
803 &route,
804 &mut bag,
805 &mut payload,
806 &pdp_arc(),
807 &plugins(),
808 &delegations(),
809 &elicitations(),
810 )
811 .await;
812 assert_eq!(r.decision, Decision::Allow);
813 assert!(r.args_modified);
814 assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789"));
815 assert_eq!(payload.args["user"]["profile"]["name"], json!("alice"));
816 }
817
818 #[tokio::test]
819 async fn nested_field_missing_intermediate_is_skipped() {
820 let mut route = CompiledRoute::new("ping");
821 route.args.push(field_rule(
822 "user.profile.ssn",
823 vec![Stage::Mask { keep_last: 4 }],
824 ));
825 let mut bag = AttributeBag::new();
826 let mut payload = RoutePayload::new(json!({ "user": { "name": "alice" } }));
828 let r = evaluate_route(
829 &route,
830 &mut bag,
831 &mut payload,
832 &pdp_arc(),
833 &plugins(),
834 &delegations(),
835 &elicitations(),
836 )
837 .await;
838 assert_eq!(r.decision, Decision::Allow);
839 assert!(!r.args_modified);
840 }
841
842 #[tokio::test]
843 async fn post_policy_runs_after_result() {
844 let mut route = CompiledRoute::new("ping");
845 route
847 .result
848 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
849 route
850 .post_policy
851 .push(Effect::from(deny_rule("post_policy[0]", "after-the-fact")));
852
853 let mut bag = AttributeBag::new();
854 let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" }));
855 let r = evaluate_route(
856 &route,
857 &mut bag,
858 &mut payload,
859 &pdp_arc(),
860 &plugins(),
861 &delegations(),
862 &elicitations(),
863 )
864 .await;
865 match r.decision {
866 Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"),
867 d => panic!("expected post_policy deny, got {:?}", d),
868 }
869 assert!(r.result_modified);
871 assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]"));
872 }
873
874 #[test]
877 fn dotted_get_simple_and_nested() {
878 let v = json!({ "a": { "b": { "c": 7 } } });
879 assert_eq!(get_dotted(&v, "a.b.c"), Some(&json!(7)));
880 assert_eq!(get_dotted(&v, "a.b"), Some(&json!({ "c": 7 })));
881 assert!(get_dotted(&v, "a.b.x").is_none());
882 assert!(get_dotted(&v, "missing").is_none());
883 }
884
885 #[test]
886 fn dotted_set_overwrites_leaf() {
887 let mut v = json!({ "a": { "b": 1 } });
888 assert!(set_dotted(&mut v, "a.b", json!(99)));
889 assert_eq!(v["a"]["b"], json!(99));
890 }
891
892 #[test]
893 fn dotted_set_does_not_create_missing_parents() {
894 let mut v = json!({});
896 assert!(!set_dotted(&mut v, "a.b", json!(1)));
897 assert_eq!(v, json!({}));
898 }
899
900 #[test]
901 fn dotted_remove_leaf() {
902 let mut v = json!({ "a": { "b": 1, "c": 2 } });
903 assert!(remove_dotted(&mut v, "a.b"));
904 assert_eq!(v, json!({ "a": { "c": 2 } }));
905 assert!(!remove_dotted(&mut v, "a.b"));
907 }
908
909 #[tokio::test]
912 async fn evaluate_pre_runs_args_and_policy_only() {
913 let mut route = CompiledRoute::new("test");
917 route
918 .args
919 .push(field_rule("id", vec![Stage::Mask { keep_last: 2 }]));
920 route
921 .result
922 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
923
924 let mut payload =
925 RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" }));
926 let mut bag = AttributeBag::new();
927 let r = evaluate_pre(
928 &route,
929 &mut bag,
930 &mut payload,
931 &pdp_arc(),
932 &plugins(),
933 &delegations(),
934 &elicitations(),
935 )
936 .await;
937 assert_eq!(r.decision, Decision::Allow);
938 assert!(
939 r.args_modified,
940 "args mask stage should have rewritten the field"
941 );
942 assert!(!r.result_modified, "evaluate_pre must not touch result");
943 assert_eq!(payload.args["id"], json!("******GH"));
945 assert_eq!(
947 payload.result.as_ref().unwrap()["ssn"],
948 json!("555-12-3456")
949 );
950 }
951
952 #[tokio::test]
953 async fn evaluate_post_runs_result_and_post_policy_only() {
954 let mut route = CompiledRoute::new("test");
957 route
958 .args
959 .push(field_rule("id", vec![Stage::Mask { keep_last: 2 }]));
960 route
961 .result
962 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
963
964 let mut payload =
965 RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" }));
966 let mut bag = AttributeBag::new();
967 let r = evaluate_post(
968 &route,
969 &mut bag,
970 &mut payload,
971 &pdp_arc(),
972 &plugins(),
973 &delegations(),
974 &elicitations(),
975 )
976 .await;
977 assert_eq!(r.decision, Decision::Allow);
978 assert!(!r.args_modified, "evaluate_post must not touch args");
979 assert!(r.result_modified, "result redact should have fired");
980 assert_eq!(payload.args["id"], json!("ABCDEFGH"));
982 assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]"));
984 }
985
986 #[tokio::test]
987 async fn evaluate_pre_deny_halts_before_policy() {
988 let mut route = CompiledRoute::new("test");
990 route
991 .args
992 .push(field_rule("id", vec![Stage::Type(TypeCheck::Uuid)]));
993 route.policy.push(Effect::from(Rule::single(
995 Expression::Always,
996 Effect::Deny {
997 reason: Some("policy_should_not_run".into()),
998 code: None,
999 },
1000 "test.policy[0]",
1001 )));
1002
1003 let mut payload = RoutePayload::new(json!({ "id": "not-a-uuid" }));
1004 let mut bag = AttributeBag::new();
1005 let r = evaluate_pre(
1006 &route,
1007 &mut bag,
1008 &mut payload,
1009 &pdp_arc(),
1010 &plugins(),
1011 &delegations(),
1012 &elicitations(),
1013 )
1014 .await;
1015 match r.decision {
1016 Decision::Deny { rule_source, .. } => {
1017 assert!(
1018 rule_source.contains("test.id"),
1019 "args denial got source {}",
1020 rule_source
1021 );
1022 },
1023 d => panic!("expected args-side Deny, got {:?}", d),
1024 }
1025 }
1026
1027 #[tokio::test]
1028 async fn evaluate_route_skips_post_on_pre_deny() {
1029 let mut route = CompiledRoute::new("test");
1032 route.policy.push(Effect::from(Rule::single(
1033 Expression::Always,
1034 Effect::Deny {
1035 reason: Some("policy_deny".into()),
1036 code: None,
1037 },
1038 "test.policy[0]",
1039 )));
1040 route
1041 .result
1042 .push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
1043 route.post_policy.push(Effect::Taint {
1044 label: "should_not_emit".into(),
1045 scopes: vec![TaintScope::Session],
1046 });
1047
1048 let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "555-12-3456" }));
1049 let mut bag = AttributeBag::new();
1050 let r = evaluate_route(
1051 &route,
1052 &mut bag,
1053 &mut payload,
1054 &pdp_arc(),
1055 &plugins(),
1056 &delegations(),
1057 &elicitations(),
1058 )
1059 .await;
1060 assert!(matches!(r.decision, Decision::Deny { .. }));
1061 assert!(!r.result_modified, "post must be skipped on pre-side Deny");
1062 assert!(r.taints.is_empty());
1064 assert_eq!(
1066 payload.result.as_ref().unwrap()["ssn"],
1067 json!("555-12-3456")
1068 );
1069 }
1070}