1use std::sync::Arc;
18
19use crate::attributes::{AttributeBag, AttributeValue};
20use crate::pipeline::{Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck};
21use crate::rules::{CompareOp, Condition, Effect, Expression, Literal, Rule};
22use crate::step::{PdpResolver, PluginInvocation, PluginInvoker};
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum Decision {
27 Allow,
29 Deny {
31 reason: Option<String>,
32 rule_source: String,
34 },
35}
36
37pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision {
53 for rule in rules {
54 if !eval_expression(&rule.condition, bag) {
55 continue;
56 }
57 for effect in &rule.effects {
58 match effect {
59 Effect::Allow => continue,
60 Effect::Deny { reason, code } => {
61 let rule_source = code.clone().unwrap_or_else(|| rule.source.clone());
65 return Decision::Deny {
66 reason: reason.clone(),
67 rule_source,
68 };
69 },
70 _ => continue,
73 }
74 }
75 }
76 Decision::Allow
77}
78
79fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool {
80 match expr {
81 Expression::Condition(c) => eval_condition(c, bag),
82 Expression::And(parts) => parts.iter().all(|e| eval_expression(e, bag)),
83 Expression::Or(parts) => parts.iter().any(|e| eval_expression(e, bag)),
84 Expression::Not(inner) => !eval_expression(inner, bag),
85 Expression::Always => true,
86 }
87}
88
89fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool {
90 match cond {
91 Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false),
92 Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false),
93 Condition::Exists { key } => bag.contains(key),
94 Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag),
95 Condition::InSet {
96 value_key,
97 set_key,
98 negate,
99 } => {
100 let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) {
101 (Some(s), Some(set)) => set.contains(s),
102 _ => false, };
104 if *negate {
105 !in_set
106 } else {
107 in_set
108 }
109 },
110 }
111}
112
113fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) -> bool {
114 let attr = match bag.get(key) {
115 Some(v) => v,
116 None => return false, };
118
119 match op {
120 CompareOp::Contains => match (attr, lit) {
121 (AttributeValue::StringSet(_), Literal::String(s)) => bag.set_contains(key, s),
122 _ => false,
123 },
124 CompareOp::Eq => values_eq(attr, lit),
125 CompareOp::NotEq => !values_eq(attr, lit),
126 CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => {
127 numeric_compare(attr, lit, op)
128 },
129 }
130}
131
132fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool {
133 match (attr, lit) {
134 (AttributeValue::Bool(a), Literal::Bool(b)) => a == b,
135 (AttributeValue::Int(a), Literal::Int(b)) => a == b,
136 (AttributeValue::Float(a), Literal::Float(b)) => a == b,
137 (AttributeValue::String(a), Literal::String(b)) => a == b,
138 (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64) == *b,
140 (AttributeValue::Float(a), Literal::Int(b)) => *a == (*b as f64),
141 _ => false,
142 }
143}
144
145fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool {
146 let (a, b) = match (attr, lit) {
147 (AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64),
148 (AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b),
149 (AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64),
150 (AttributeValue::Float(a), Literal::Float(b)) => (*a, *b),
151 _ => return false,
153 };
154 match op {
155 CompareOp::Gt => a > b,
156 CompareOp::GtEq => a >= b,
157 CompareOp::Lt => a < b,
158 CompareOp::LtEq => a <= b,
159 _ => unreachable!("numeric_compare called with non-numeric op"),
160 }
161}
162
163#[allow(clippy::too_many_arguments)]
194pub async fn evaluate_effects(
195 effects: &[Effect],
196 bag: &mut AttributeBag,
197 pdp: &Arc<dyn PdpResolver>,
198 plugins: &Arc<dyn PluginInvoker>,
199 delegations: &Arc<dyn crate::step::DelegationInvoker>,
200 phase: crate::step::DispatchPhase,
201 payload: &mut crate::route::RoutePayload,
202) -> StepsEvaluation {
203 let mut taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
204 let mut args_modified = false;
205 let mut result_modified = false;
206 for effect in effects {
207 let fallback_source = match effect {
211 Effect::When { source, .. } => source.as_str(),
212 _ => "",
213 };
214 match Box::pin(dispatch_effect(
215 effect,
216 fallback_source,
217 bag,
218 pdp,
219 plugins,
220 delegations,
221 phase,
222 &mut taints,
223 &mut args_modified,
224 &mut result_modified,
225 payload,
226 ))
227 .await
228 {
229 EffectOutcome::Continue => {},
230 EffectOutcome::Halt(decision) => {
231 return StepsEvaluation::deny(decision, taints, args_modified, result_modified);
232 },
233 }
234 }
235 StepsEvaluation {
236 decision: Decision::Allow,
237 taints,
238 args_modified,
239 result_modified,
240 }
241}
242
243#[derive(Debug, Clone)]
254pub struct StepsEvaluation {
255 pub decision: Decision,
256 pub taints: Vec<crate::pipeline::TaintEvent>,
257 pub args_modified: bool,
258 pub result_modified: bool,
259}
260
261impl StepsEvaluation {
262 fn deny(
263 d: Decision,
264 taints: Vec<crate::pipeline::TaintEvent>,
265 args_modified: bool,
266 result_modified: bool,
267 ) -> Self {
268 Self {
269 decision: d,
270 taints,
271 args_modified,
272 result_modified,
273 }
274 }
275}
276
277enum EffectOutcome {
283 Continue,
286 Halt(Decision),
289}
290
291#[allow(clippy::too_many_arguments)]
303async fn dispatch_effect(
304 effect: &Effect,
305 fallback_source: &str,
306 bag: &mut AttributeBag,
307 pdp: &Arc<dyn PdpResolver>,
308 plugins: &Arc<dyn PluginInvoker>,
309 delegations: &Arc<dyn crate::step::DelegationInvoker>,
310 phase: crate::step::DispatchPhase,
311 taints: &mut Vec<crate::pipeline::TaintEvent>,
312 args_modified: &mut bool,
313 result_modified: &mut bool,
314 payload: &mut crate::route::RoutePayload,
315) -> EffectOutcome {
316 match effect {
317 Effect::Allow => EffectOutcome::Continue,
318
319 Effect::Deny { reason, code } => {
320 let rule_source = code.clone().unwrap_or_else(|| fallback_source.to_string());
325 EffectOutcome::Halt(Decision::Deny {
326 reason: reason.clone(),
327 rule_source,
328 })
329 },
330
331 Effect::Plugin { name } => {
332 match plugins
333 .invoke(name, bag, PluginInvocation::Step { phase })
334 .await
335 {
336 Ok(outcome) => {
337 taints.extend(outcome.taints);
340 match outcome.decision {
341 Decision::Allow => EffectOutcome::Continue,
342 deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny),
343 }
344 },
345 Err(e) => EffectOutcome::Halt(Decision::Deny {
346 reason: Some(format!("plugin `{}` error: {}", name, e)),
347 rule_source: format!("plugin:{}", name),
348 }),
349 }
350 },
351
352 Effect::Delegate(delegate_step) => {
353 match delegations.delegate(delegate_step).await {
354 Ok(outcome) => match &outcome.decision {
355 Decision::Allow => {
356 use crate::attributes::AttributeValue;
361 use crate::step::delegation_bag_keys as bk;
362
363 bag.set(bk::GRANTED, AttributeValue::Bool(true));
364 if !outcome.granted_permissions.is_empty() {
365 let set: std::collections::HashSet<String> =
366 outcome.granted_permissions.iter().cloned().collect();
367 bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set));
368 }
369 if let Some(aud) = &outcome.granted_audience {
370 bag.set(bk::GRANTED_AUDIENCE, aud.clone());
371 }
372 if let Some(exp) = &outcome.granted_expires_at {
373 bag.set(bk::GRANTED_EXPIRES_AT, exp.clone());
374 }
375 EffectOutcome::Continue
376 },
377 Decision::Deny { .. } => {
378 let on_error = delegate_step
383 .on_error
384 .as_deref()
385 .unwrap_or("deny")
386 .to_ascii_lowercase();
387 if on_error == "continue" {
388 EffectOutcome::Continue
389 } else {
390 EffectOutcome::Halt(outcome.decision)
391 }
392 },
393 },
394 Err(e) => {
395 let on_error = delegate_step
398 .on_error
399 .as_deref()
400 .unwrap_or("deny")
401 .to_ascii_lowercase();
402 if on_error == "continue" {
403 EffectOutcome::Continue
404 } else {
405 EffectOutcome::Halt(Decision::Deny {
406 reason: Some(format!(
407 "delegate `{}` error: {}",
408 delegate_step.plugin_name, e
409 )),
410 rule_source: delegate_step.source.clone(),
411 })
412 }
413 },
414 }
415 },
416
417 Effect::Taint { label, scopes } => {
418 taints.push(crate::pipeline::TaintEvent {
424 label: label.clone(),
425 scopes: scopes.clone(),
426 });
427 EffectOutcome::Continue
428 },
429
430 Effect::FieldOp { path, stages } => {
431 dispatch_field_op(
432 path,
433 stages,
434 fallback_source,
435 bag,
436 plugins,
437 phase,
438 taints,
439 args_modified,
440 result_modified,
441 payload,
442 )
443 .await
444 },
445
446 Effect::Sequential(effects) => {
447 for inner in effects {
452 match Box::pin(dispatch_effect(
453 inner,
454 fallback_source,
455 bag,
456 pdp,
457 plugins,
458 delegations,
459 phase,
460 taints,
461 args_modified,
462 result_modified,
463 payload,
464 ))
465 .await
466 {
467 EffectOutcome::Continue => continue,
468 halt @ EffectOutcome::Halt(_) => return halt,
469 }
470 }
471 EffectOutcome::Continue
472 },
473
474 Effect::Parallel(effects) => {
475 dispatch_parallel(
480 effects,
481 fallback_source,
482 bag,
483 pdp,
484 plugins,
485 delegations,
486 phase,
487 taints,
488 payload,
489 )
490 .await
491 },
492
493 Effect::When {
494 condition,
495 body,
496 source,
497 } => {
498 if !eval_expression(condition, bag) {
502 return EffectOutcome::Continue;
503 }
504 for inner in body {
505 match Box::pin(dispatch_effect(
506 inner,
507 source,
508 bag,
509 pdp,
510 plugins,
511 delegations,
512 phase,
513 taints,
514 args_modified,
515 result_modified,
516 payload,
517 ))
518 .await
519 {
520 EffectOutcome::Continue => continue,
521 halt @ EffectOutcome::Halt(_) => return halt,
522 }
523 }
524 EffectOutcome::Continue
525 },
526
527 Effect::Pdp {
528 call,
529 on_allow,
530 on_deny,
531 } => {
532 match pdp.evaluate(call, bag).await {
535 Ok(pdp_result) => match pdp_result.decision {
536 Decision::Allow => {
537 for inner in on_allow {
540 match Box::pin(dispatch_effect(
541 inner,
542 fallback_source,
543 bag,
544 pdp,
545 plugins,
546 delegations,
547 phase,
548 taints,
549 args_modified,
550 result_modified,
551 payload,
552 ))
553 .await
554 {
555 EffectOutcome::Continue => continue,
556 halt @ EffectOutcome::Halt(_) => return halt,
557 }
558 }
559 EffectOutcome::Continue
560 },
561 deny @ Decision::Deny { .. } => {
562 for inner in on_deny {
567 if let EffectOutcome::Halt(reaction_decision) =
568 Box::pin(dispatch_effect(
569 inner,
570 fallback_source,
571 bag,
572 pdp,
573 plugins,
574 delegations,
575 phase,
576 taints,
577 args_modified,
578 result_modified,
579 payload,
580 ))
581 .await
582 {
583 return EffectOutcome::Halt(reaction_decision);
584 }
585 }
586 EffectOutcome::Halt(deny)
587 },
588 },
589 Err(e) => EffectOutcome::Halt(Decision::Deny {
590 reason: Some(format!("PDP error: {}", e)),
591 rule_source: format!("pdp:{:?}", call.dialect),
592 }),
593 }
594 },
595 }
596}
597
598fn dispatch_parallel<'a>(
636 effects: &'a [Effect],
637 fallback_source: &'a str,
638 bag: &'a AttributeBag,
639 pdp: &'a Arc<dyn PdpResolver>,
640 plugins: &'a Arc<dyn PluginInvoker>,
641 delegations: &'a Arc<dyn crate::step::DelegationInvoker>,
642 phase: crate::step::DispatchPhase,
643 taints: &'a mut Vec<crate::pipeline::TaintEvent>,
644 payload: &'a crate::route::RoutePayload,
645) -> futures::future::BoxFuture<'a, EffectOutcome> {
646 Box::pin(async move {
647 use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
648
649 if effects.is_empty() {
650 return EffectOutcome::Continue;
651 }
652
653 let mut branches: Vec<ErasedBranch<(EffectOutcome, Vec<crate::pipeline::TaintEvent>)>> =
662 Vec::with_capacity(effects.len());
663 for effect in effects.iter() {
664 let effect = effect.clone();
665 let fallback = fallback_source.to_string();
666 let mut branch_bag = bag.clone();
667 let mut branch_payload = payload.clone();
668 let pdp = Arc::clone(pdp);
669 let plugins = Arc::clone(plugins);
670 let delegations = Arc::clone(delegations);
671 branches.push(Box::pin(async move {
672 let mut branch_taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
673 let mut branch_args_modified = false;
674 let mut branch_result_modified = false;
675 let outcome = Box::pin(dispatch_effect(
676 &effect,
677 &fallback,
678 &mut branch_bag,
679 &pdp,
680 &plugins,
681 &delegations,
682 phase,
683 &mut branch_taints,
684 &mut branch_args_modified,
685 &mut branch_result_modified,
686 &mut branch_payload,
687 ))
688 .await;
689 (outcome, branch_taints)
690 }));
691 }
692
693 let cfg = BranchConfig {
698 timeout_per_branch: None,
699 short_circuit_on_deny: true,
700 };
701 let outcomes = run_branches(
702 branches,
703 cfg,
704 |v: &(EffectOutcome, Vec<crate::pipeline::TaintEvent>)| {
705 matches!(v.0, EffectOutcome::Halt(_))
706 },
707 )
708 .await;
709
710 let mut first_halt: Option<Decision> = None;
718 for (idx, outcome) in outcomes.into_iter().enumerate() {
719 match outcome {
720 BranchOutcome::Completed((effect_outcome, branch_taints)) => {
721 taints.extend(branch_taints);
722 if first_halt.is_none() {
723 if let EffectOutcome::Halt(d) = effect_outcome {
724 first_halt = Some(d);
725 }
726 }
727 },
728 BranchOutcome::Aborted => {
729 },
732 BranchOutcome::TimedOut => {
733 },
737 BranchOutcome::Panicked(msg) => {
738 let _ = (idx, msg);
746 },
747 }
748 }
749
750 match first_halt {
751 Some(d) => EffectOutcome::Halt(d),
752 None => EffectOutcome::Continue,
753 }
754 })
755}
756
757#[allow(clippy::too_many_arguments)]
772async fn dispatch_field_op(
773 path: &str,
774 stages: &[crate::pipeline::Stage],
775 fallback_source: &str,
776 bag: &mut AttributeBag,
777 plugins: &Arc<dyn PluginInvoker>,
778 phase: crate::step::DispatchPhase,
779 taints: &mut Vec<crate::pipeline::TaintEvent>,
780 args_modified: &mut bool,
781 result_modified: &mut bool,
782 payload: &mut crate::route::RoutePayload,
783) -> EffectOutcome {
784 use crate::route::{get_dotted, remove_dotted, set_dotted};
785 use crate::step::DispatchPhase;
786
787 enum Side {
790 Args,
791 Result,
792 }
793 let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") {
794 if !matches!(phase, DispatchPhase::Pre) {
795 return EffectOutcome::Continue;
796 }
797 (&mut payload.args, rest, Side::Args)
798 } else if let Some(rest) = path.strip_prefix("result.") {
799 if !matches!(phase, DispatchPhase::Post) {
800 return EffectOutcome::Continue;
801 }
802 let Some(result) = payload.result.as_mut() else {
803 return EffectOutcome::Continue;
804 };
805 (result, rest, Side::Result)
806 } else {
807 return EffectOutcome::Halt(Decision::Deny {
808 reason: Some(format!(
809 "FieldOp path `{}` must start with `args.` or `result.`",
810 path
811 )),
812 rule_source: fallback_source.to_string(),
813 });
814 };
815
816 let Some(current) = get_dotted(root, subpath).cloned() else {
817 return EffectOutcome::Continue; };
819
820 let pipeline = crate::pipeline::Pipeline {
821 stages: stages.to_vec(),
822 };
823 let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, phase).await;
824 taints.extend(eval.taints);
825 let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side {
826 Side::Args => *args = true,
827 Side::Result => *result = true,
828 };
829 match eval.outcome {
830 FieldOutcome::Pass => EffectOutcome::Continue,
831 FieldOutcome::Replace(new_val) => {
832 if set_dotted(root, subpath, new_val) {
833 mark_modified(side, args_modified, result_modified);
834 }
835 EffectOutcome::Continue
836 },
837 FieldOutcome::Omit => {
838 if remove_dotted(root, subpath) {
839 mark_modified(side, args_modified, result_modified);
840 }
841 EffectOutcome::Continue
842 },
843 FieldOutcome::Deny {
844 reason,
845 stage_index: _,
846 } => EffectOutcome::Halt(Decision::Deny {
847 reason: Some(reason),
848 rule_source: fallback_source.to_string(),
849 }),
850 }
851}
852
853#[derive(Debug, Clone, PartialEq)]
865pub enum FieldOutcome {
866 Pass,
867 Replace(serde_json::Value),
868 Omit,
869 Deny { reason: String, stage_index: usize },
870}
871
872#[derive(Debug, Clone, PartialEq)]
881pub struct PipelineEvaluation {
882 pub outcome: FieldOutcome,
883 pub taints: Vec<TaintEvent>,
884}
885
886pub async fn evaluate_pipeline(
899 pipeline: &Pipeline,
900 value: &serde_json::Value,
901 bag: &AttributeBag,
902 plugins: &Arc<dyn PluginInvoker>,
903 field_name: &str,
904 phase: crate::step::DispatchPhase,
905) -> PipelineEvaluation {
906 let mut current = value.clone();
907 let mut replaced = false;
908 let mut taints: Vec<TaintEvent> = Vec::new();
909
910 for (idx, stage) in pipeline.stages.iter().enumerate() {
911 match stage {
912 Stage::Type(tc) => {
914 if !type_check(tc, ¤t) {
915 return PipelineEvaluation {
916 outcome: FieldOutcome::Deny {
917 reason: format!("expected {:?}, got {}", tc, value_kind(¤t)),
918 stage_index: idx,
919 },
920 taints,
921 };
922 }
923 },
924 Stage::Length { min, max } => {
925 let Some(s) = current.as_str() else {
926 return PipelineEvaluation {
927 outcome: FieldOutcome::Deny {
928 reason: format!(
929 "len(...) requires string value, got {}",
930 value_kind(¤t)
931 ),
932 stage_index: idx,
933 },
934 taints,
935 };
936 };
937 let len = s.chars().count();
938 if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) {
939 return PipelineEvaluation {
940 outcome: FieldOutcome::Deny {
941 reason: format!("length {} outside [{:?}, {:?}]", len, min, max),
942 stage_index: idx,
943 },
944 taints,
945 };
946 }
947 },
948 Stage::Range { min, max } => {
949 let Some(n) = current.as_i64() else {
950 return PipelineEvaluation {
951 outcome: FieldOutcome::Deny {
952 reason: format!(
953 "range requires integer value, got {}",
954 value_kind(¤t)
955 ),
956 stage_index: idx,
957 },
958 taints,
959 };
960 };
961 if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) {
962 return PipelineEvaluation {
963 outcome: FieldOutcome::Deny {
964 reason: format!("value {} outside [{:?}, {:?}]", n, min, max),
965 stage_index: idx,
966 },
967 taints,
968 };
969 }
970 },
971 Stage::Enum { values } => {
972 let Some(s) = current.as_str() else {
973 return PipelineEvaluation {
974 outcome: FieldOutcome::Deny {
975 reason: format!(
976 "enum(...) requires string value, got {}",
977 value_kind(¤t)
978 ),
979 stage_index: idx,
980 },
981 taints,
982 };
983 };
984 if !values.iter().any(|v| v == s) {
985 return PipelineEvaluation {
986 outcome: FieldOutcome::Deny {
987 reason: format!("value `{}` not in enum {:?}", s, values),
988 stage_index: idx,
989 },
990 taints,
991 };
992 }
993 },
994 Stage::Regex { pattern } => {
995 let re = match regex::Regex::new(pattern) {
998 Ok(r) => r,
999 Err(e) => {
1000 return PipelineEvaluation {
1001 outcome: FieldOutcome::Deny {
1002 reason: format!("invalid regex `{}`: {}", pattern, e),
1003 stage_index: idx,
1004 },
1005 taints,
1006 };
1007 },
1008 };
1009 let Some(s) = current.as_str() else {
1010 return PipelineEvaluation {
1011 outcome: FieldOutcome::Deny {
1012 reason: format!(
1013 "regex requires string value, got {}",
1014 value_kind(¤t)
1015 ),
1016 stage_index: idx,
1017 },
1018 taints,
1019 };
1020 };
1021 if !re.is_match(s) {
1022 return PipelineEvaluation {
1023 outcome: FieldOutcome::Deny {
1024 reason: format!("value did not match regex `{}`", pattern),
1025 stage_index: idx,
1026 },
1027 taints,
1028 };
1029 }
1030 },
1031 Stage::Validate { name } => {
1032 return PipelineEvaluation {
1039 outcome: FieldOutcome::Deny {
1040 reason: format!(
1041 "`validate({})` is not implemented; use `regex(...)` \
1042 or `plugin({})` instead",
1043 name, name,
1044 ),
1045 stage_index: idx,
1046 },
1047 taints,
1048 };
1049 },
1050
1051 Stage::Mask { keep_last } => {
1053 let Some(s) = current.as_str() else {
1054 return PipelineEvaluation {
1055 outcome: FieldOutcome::Deny {
1056 reason: format!(
1057 "mask(...) requires string value, got {}",
1058 value_kind(¤t)
1059 ),
1060 stage_index: idx,
1061 },
1062 taints,
1063 };
1064 };
1065 let chars: Vec<char> = s.chars().collect();
1066 let keep = (*keep_last).min(chars.len());
1067 let mask_count = chars.len() - keep;
1068 let masked: String = std::iter::repeat('*')
1069 .take(mask_count)
1070 .chain(chars.into_iter().skip(mask_count))
1071 .collect();
1072 current = serde_json::Value::String(masked);
1073 replaced = true;
1074 },
1075 Stage::Redact { condition } => {
1076 let should_redact = match condition {
1077 None => true,
1078 Some(expr) => eval_expression(expr, bag),
1079 };
1080 if should_redact {
1081 current = serde_json::Value::String("[REDACTED]".into());
1082 replaced = true;
1083 }
1084 },
1085 Stage::Omit => {
1086 return PipelineEvaluation {
1087 outcome: FieldOutcome::Omit,
1088 taints,
1089 };
1090 },
1091 Stage::Hash => {
1092 use std::hash::{Hash, Hasher};
1095 let mut h = std::collections::hash_map::DefaultHasher::new();
1096 value_for_hash(¤t).hash(&mut h);
1097 current = serde_json::Value::String(format!("hash:{:016x}", h.finish()));
1098 replaced = true;
1099 },
1100
1101 Stage::Taint { label, scopes } => {
1103 taints.push(TaintEvent {
1104 label: label.clone(),
1105 scopes: scopes.clone(),
1106 });
1107 },
1108 Stage::Plugin { name } => {
1109 let invocation = PluginInvocation::Field {
1110 name: field_name,
1111 value: ¤t,
1112 phase,
1113 };
1114 match plugins.invoke(name, bag, invocation).await {
1115 Ok(outcome) => {
1116 taints.extend(outcome.taints);
1118 match outcome.decision {
1119 Decision::Allow => {
1120 if let Some(new_value) = outcome.modified_value {
1121 current = new_value;
1122 replaced = true;
1123 }
1124 },
1125 Decision::Deny {
1126 reason,
1127 rule_source: _,
1128 } => {
1129 return PipelineEvaluation {
1130 outcome: FieldOutcome::Deny {
1131 reason: reason
1132 .unwrap_or_else(|| format!("plugin `{}` denied", name)),
1133 stage_index: idx,
1134 },
1135 taints,
1136 };
1137 },
1138 }
1139 },
1140 Err(e) => {
1141 return PipelineEvaluation {
1143 outcome: FieldOutcome::Deny {
1144 reason: format!("plugin `{}` error: {}", name, e),
1145 stage_index: idx,
1146 },
1147 taints,
1148 };
1149 },
1150 }
1151 },
1152 Stage::Scan { kind } => {
1153 let (label, redact): (&str, bool) = match kind {
1159 ScanKind::PiiDetect => ("PII", false),
1160 ScanKind::PiiRedact => ("PII", true),
1161 ScanKind::InjectionScan => ("injection", false),
1162 };
1163 taints.push(TaintEvent {
1164 label: label.to_string(),
1165 scopes: vec![TaintScope::Session],
1166 });
1167 if redact {
1168 current = serde_json::Value::String("[REDACTED]".into());
1169 replaced = true;
1170 }
1171 },
1172 }
1173 }
1174
1175 let outcome = if replaced {
1176 FieldOutcome::Replace(current)
1177 } else {
1178 FieldOutcome::Pass
1179 };
1180 PipelineEvaluation { outcome, taints }
1181}
1182
1183fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool {
1184 match tc {
1185 TypeCheck::Str => v.is_string(),
1186 TypeCheck::Int => v.is_i64(),
1187 TypeCheck::Bool => v.is_boolean(),
1188 TypeCheck::Float => v.is_f64() || v.is_i64(),
1189 TypeCheck::Email => v
1190 .as_str()
1191 .map_or(false, |s| s.contains('@') && s.contains('.')),
1192 TypeCheck::Url => v.as_str().map_or(false, |s| {
1193 s.starts_with("http://") || s.starts_with("https://")
1194 }),
1195 TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape),
1196 }
1197}
1198
1199fn is_uuid_shape(s: &str) -> bool {
1200 let bytes = s.as_bytes();
1202 if bytes.len() != 36 {
1203 return false;
1204 }
1205 for (i, &b) in bytes.iter().enumerate() {
1206 match i {
1207 8 | 13 | 18 | 23 => {
1208 if b != b'-' {
1209 return false;
1210 }
1211 },
1212 _ => {
1213 if !b.is_ascii_hexdigit() {
1214 return false;
1215 }
1216 },
1217 }
1218 }
1219 true
1220}
1221
1222fn value_kind(v: &serde_json::Value) -> &'static str {
1223 match v {
1224 serde_json::Value::Null => "null",
1225 serde_json::Value::Bool(_) => "bool",
1226 serde_json::Value::Number(n) if n.is_i64() => "int",
1227 serde_json::Value::Number(_) => "float",
1228 serde_json::Value::String(_) => "string",
1229 serde_json::Value::Array(_) => "array",
1230 serde_json::Value::Object(_) => "object",
1231 }
1232}
1233
1234fn value_for_hash(v: &serde_json::Value) -> String {
1237 serde_json::to_string(v).unwrap_or_default()
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243 use crate::rules::{Condition, Expression, Rule};
1244 use crate::step::{DelegationInvoker, NoopDelegationInvoker};
1245 use std::collections::HashSet;
1246 use std::sync::Arc;
1247
1248 fn rule(condition: Expression, effect: Effect, source: &str) -> Rule {
1249 Rule::single(condition, effect, source)
1250 }
1251
1252 fn null_pipe_plugins() -> Arc<dyn PluginInvoker> {
1257 Arc::new(NullPipelinePlugins)
1258 }
1259 fn null_plugins() -> Arc<dyn PluginInvoker> {
1260 Arc::new(NullPlugins)
1261 }
1262 fn noop_delegations() -> Arc<dyn DelegationInvoker> {
1263 Arc::new(NoopDelegationInvoker)
1264 }
1265
1266 fn deny(reason: &str) -> Effect {
1267 Effect::Deny {
1268 reason: Some(reason.into()),
1269 code: None,
1270 }
1271 }
1272
1273 fn cond(c: Condition) -> Expression {
1274 Expression::Condition(c)
1275 }
1276
1277 #[test]
1280 fn empty_rules_allow() {
1281 let mut bag = AttributeBag::new();
1282 assert_eq!(evaluate_rules(&[], &bag), Decision::Allow);
1283 }
1284
1285 #[test]
1286 fn first_deny_halts() {
1287 let mut bag = AttributeBag::new();
1288 bag.set("a", true);
1289 bag.set("b", true);
1290
1291 let rules = vec![
1292 rule(
1293 cond(Condition::IsTrue { key: "a".into() }),
1294 deny("first"),
1295 "r0",
1296 ),
1297 rule(
1298 cond(Condition::IsTrue { key: "b".into() }),
1299 deny("second"),
1300 "r1",
1301 ),
1302 ];
1303
1304 match evaluate_rules(&rules, &bag) {
1305 Decision::Deny {
1306 reason,
1307 rule_source,
1308 } => {
1309 assert_eq!(reason.as_deref(), Some("first"));
1310 assert_eq!(rule_source, "r0");
1311 },
1312 d => panic!("expected Deny, got {:?}", d),
1313 }
1314 }
1315
1316 #[test]
1317 fn allow_does_not_short_circuit() {
1318 let mut bag = AttributeBag::new();
1320 bag.set("ok", true);
1321 bag.set("bad", true);
1322
1323 let rules = vec![
1324 rule(
1325 cond(Condition::IsTrue { key: "ok".into() }),
1326 Effect::Allow,
1327 "r0_allow",
1328 ),
1329 rule(
1330 cond(Condition::IsTrue { key: "bad".into() }),
1331 deny("later"),
1332 "r1_deny",
1333 ),
1334 ];
1335
1336 match evaluate_rules(&rules, &bag) {
1337 Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"),
1338 d => panic!("allow short-circuited; expected later deny, got {:?}", d),
1339 }
1340 }
1341
1342 #[test]
1343 fn unmatched_rules_dont_fire() {
1344 let mut bag = AttributeBag::new(); let rules = vec![rule(
1346 cond(Condition::IsTrue {
1347 key: "denied".into(),
1348 }),
1349 deny("shouldn't fire"),
1350 "r0",
1351 )];
1352 assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1353 }
1354
1355 #[test]
1358 fn missing_key_is_false() {
1359 let mut bag = AttributeBag::new();
1360 assert!(!eval_condition(
1361 &Condition::IsTrue {
1362 key: "missing".into()
1363 },
1364 &bag
1365 ));
1366 assert!(eval_condition(
1367 &Condition::IsFalse {
1368 key: "missing".into()
1369 },
1370 &bag
1371 ));
1372 assert!(!eval_condition(
1374 &Condition::Comparison {
1375 key: "missing".into(),
1376 op: CompareOp::Eq,
1377 value: 1_i64.into(),
1378 },
1379 &bag,
1380 ));
1381 }
1382
1383 #[test]
1384 fn and_or_not_combinators() {
1385 let mut bag = AttributeBag::new();
1386 bag.set("a", true);
1387 bag.set("b", false);
1388
1389 let a = cond(Condition::IsTrue { key: "a".into() });
1390 let b = cond(Condition::IsTrue { key: "b".into() });
1391
1392 assert!(eval_expression(
1393 &Expression::And(vec![a.clone(), a.clone()]),
1394 &bag
1395 ));
1396 assert!(!eval_expression(
1397 &Expression::And(vec![a.clone(), b.clone()]),
1398 &bag
1399 ));
1400 assert!(eval_expression(
1401 &Expression::Or(vec![a.clone(), b.clone()]),
1402 &bag
1403 ));
1404 assert!(!eval_expression(
1405 &Expression::Or(vec![b.clone(), b.clone()]),
1406 &bag
1407 ));
1408 assert!(eval_expression(&Expression::Not(Box::new(b)), &bag));
1409 }
1410
1411 #[test]
1414 fn int_comparisons() {
1415 let mut bag = AttributeBag::new();
1416 bag.set("delegation.depth", 3_i64);
1417
1418 let cmp = |op| Condition::Comparison {
1419 key: "delegation.depth".into(),
1420 op,
1421 value: 2_i64.into(),
1422 };
1423 assert!(eval_condition(&cmp(CompareOp::Gt), &bag));
1424 assert!(eval_condition(&cmp(CompareOp::GtEq), &bag));
1425 assert!(!eval_condition(&cmp(CompareOp::Lt), &bag));
1426 assert!(!eval_condition(&cmp(CompareOp::Eq), &bag));
1427 assert!(eval_condition(&cmp(CompareOp::NotEq), &bag));
1428 }
1429
1430 #[test]
1431 fn int_to_float_promotion_in_comparison() {
1432 let mut bag = AttributeBag::new();
1433 bag.set("delegation.depth", 2_i64);
1434 assert!(!eval_condition(
1436 &Condition::Comparison {
1437 key: "delegation.depth".into(),
1438 op: CompareOp::Gt,
1439 value: 2.5_f64.into(),
1440 },
1441 &bag,
1442 ));
1443 assert!(eval_condition(
1444 &Condition::Comparison {
1445 key: "delegation.depth".into(),
1446 op: CompareOp::Lt,
1447 value: 2.5_f64.into(),
1448 },
1449 &bag,
1450 ));
1451 }
1452
1453 #[test]
1454 fn string_equality_no_ordering() {
1455 let mut bag = AttributeBag::new();
1456 bag.set("subject.id", "alice");
1457
1458 assert!(eval_condition(
1459 &Condition::Comparison {
1460 key: "subject.id".into(),
1461 op: CompareOp::Eq,
1462 value: "alice".into(),
1463 },
1464 &bag,
1465 ));
1466 assert!(!eval_condition(
1468 &Condition::Comparison {
1469 key: "subject.id".into(),
1470 op: CompareOp::Gt,
1471 value: "alice".into(),
1472 },
1473 &bag,
1474 ));
1475 }
1476
1477 #[test]
1478 fn contains_set_membership() {
1479 let mut bag = AttributeBag::new();
1480 bag.set(
1481 "session.labels",
1482 HashSet::from(["PII".to_string(), "financial".to_string()]),
1483 );
1484
1485 assert!(eval_condition(
1486 &Condition::Comparison {
1487 key: "session.labels".into(),
1488 op: CompareOp::Contains,
1489 value: "PII".into(),
1490 },
1491 &bag,
1492 ));
1493 assert!(!eval_condition(
1494 &Condition::Comparison {
1495 key: "session.labels".into(),
1496 op: CompareOp::Contains,
1497 value: "PHI".into(),
1498 },
1499 &bag,
1500 ));
1501 bag.set("subject.id", "alice");
1503 assert!(!eval_condition(
1504 &Condition::Comparison {
1505 key: "subject.id".into(),
1506 op: CompareOp::Contains,
1507 value: "alice".into(),
1508 },
1509 &bag,
1510 ));
1511 }
1512
1513 #[test]
1516 fn hr_compensation_scenario() {
1517 let mut bag = AttributeBag::new();
1524 bag.set("authenticated", true);
1525 bag.set("role.hr", true);
1526 bag.set("perm.view_ssn", true);
1527 bag.set("delegation.depth", 1_i64);
1528 bag.set("include_ssn", true);
1529
1530 let rules = vec![
1531 rule(
1533 Expression::Not(Box::new(cond(Condition::IsTrue {
1534 key: "authenticated".into(),
1535 }))),
1536 deny("not authenticated"),
1537 "r0",
1538 ),
1539 rule(
1543 Expression::And(vec![
1544 cond(Condition::IsFalse {
1545 key: "role.hr".into(),
1546 }),
1547 cond(Condition::IsFalse {
1548 key: "role.finance".into(),
1549 }),
1550 ]),
1551 deny("not in hr/finance"),
1552 "r1",
1553 ),
1554 rule(
1556 Expression::And(vec![
1557 cond(Condition::Comparison {
1558 key: "delegation.depth".into(),
1559 op: CompareOp::Gt,
1560 value: 2_i64.into(),
1561 }),
1562 cond(Condition::IsTrue {
1563 key: "include_ssn".into(),
1564 }),
1565 ]),
1566 deny("delegation too deep for SSN"),
1567 "r2",
1568 ),
1569 ];
1570
1571 assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1572
1573 bag.set("delegation.depth", 3_i64);
1576 match evaluate_rules(&rules, &bag) {
1577 Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"),
1578 d => panic!("expected r2 deny, got {:?}", d),
1579 }
1580 }
1581
1582 use crate::pipeline::{Stage, TypeCheck};
1587 use serde_json::json;
1588
1589 fn make_pipeline(stages: Vec<Stage>) -> crate::pipeline::Pipeline {
1590 crate::pipeline::Pipeline { stages }
1591 }
1592
1593 async fn run_pipeline(
1598 p: &crate::pipeline::Pipeline,
1599 v: &serde_json::Value,
1600 bag: &AttributeBag,
1601 ) -> FieldOutcome {
1602 evaluate_pipeline(
1603 p,
1604 v,
1605 bag,
1606 &null_pipe_plugins(),
1607 "test_field",
1608 crate::step::DispatchPhase::Pre,
1609 )
1610 .await
1611 .outcome
1612 }
1613
1614 struct NullPipelinePlugins;
1618 #[async_trait]
1619 impl PluginInvoker for NullPipelinePlugins {
1620 async fn invoke(
1621 &self,
1622 name: &str,
1623 _bag: &AttributeBag,
1624 _invocation: PluginInvocation<'_>,
1625 ) -> Result<PluginOutcome, PluginError> {
1626 panic!(
1627 "NullPipelinePlugins should not dispatch; got plugin({})",
1628 name
1629 );
1630 }
1631 }
1632
1633 #[tokio::test]
1634 async fn pipeline_empty_is_pass() {
1635 let mut bag = AttributeBag::new();
1636 let p = make_pipeline(vec![]);
1637 assert_eq!(
1638 run_pipeline(&p, &json!("anything"), &bag).await,
1639 FieldOutcome::Pass
1640 );
1641 }
1642
1643 #[tokio::test]
1644 async fn pipeline_type_check_passes_and_denies() {
1645 let mut bag = AttributeBag::new();
1646 let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]);
1647 assert_eq!(
1648 run_pipeline(&p, &json!("hello"), &bag).await,
1649 FieldOutcome::Pass
1650 );
1651 match run_pipeline(&p, &json!(42), &bag).await {
1652 FieldOutcome::Deny {
1653 reason,
1654 stage_index,
1655 } => {
1656 assert!(reason.contains("expected Str"));
1657 assert_eq!(stage_index, 0);
1658 },
1659 other => panic!("expected Deny, got {:?}", other),
1660 }
1661 }
1662
1663 #[tokio::test]
1664 async fn pipeline_mask_preserves_last_n() {
1665 let mut bag = AttributeBag::new();
1666 let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
1667 match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1668 FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")),
1669 other => panic!("expected Replace, got {:?}", other),
1670 }
1671 }
1672
1673 #[tokio::test]
1674 async fn pipeline_mask_handles_short_strings() {
1675 let mut bag = AttributeBag::new();
1676 let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
1677 match run_pipeline(&p, &json!("ab"), &bag).await {
1679 FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")),
1680 other => panic!("expected Replace, got {:?}", other),
1681 }
1682 }
1683
1684 #[tokio::test]
1685 async fn pipeline_unconditional_redact() {
1686 let mut bag = AttributeBag::new();
1687 let p = make_pipeline(vec![Stage::Redact { condition: None }]);
1688 match run_pipeline(&p, &json!("secret"), &bag).await {
1689 FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
1690 other => panic!("expected Replace, got {:?}", other),
1691 }
1692 }
1693
1694 #[tokio::test]
1695 async fn pipeline_conditional_redact_fires_when_condition_true() {
1696 let mut bag = AttributeBag::new();
1699 let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
1700 key: "perm.view_ssn".into(),
1701 })));
1702 let p = make_pipeline(vec![Stage::Redact {
1703 condition: Some(cond),
1704 }]);
1705 match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1706 FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
1707 other => panic!("expected Replace (redact fired), got {:?}", other),
1708 }
1709 }
1710
1711 #[tokio::test]
1712 async fn pipeline_conditional_redact_skips_when_condition_false() {
1713 let mut bag = AttributeBag::new();
1714 bag.set("perm.view_ssn", true);
1715 let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
1716 key: "perm.view_ssn".into(),
1717 })));
1718 let p = make_pipeline(vec![Stage::Redact {
1719 condition: Some(cond),
1720 }]);
1721 assert_eq!(
1723 run_pipeline(&p, &json!("123-45-6789"), &bag).await,
1724 FieldOutcome::Pass,
1725 );
1726 }
1727
1728 #[tokio::test]
1729 async fn pipeline_omit_short_circuits() {
1730 let mut bag = AttributeBag::new();
1731 let p = make_pipeline(vec![
1732 Stage::Omit,
1733 Stage::Type(TypeCheck::Int),
1735 ]);
1736 assert_eq!(
1737 run_pipeline(&p, &json!("anything"), &bag).await,
1738 FieldOutcome::Omit
1739 );
1740 }
1741
1742 #[tokio::test]
1743 async fn pipeline_range_validator() {
1744 let mut bag = AttributeBag::new();
1745 let p = make_pipeline(vec![
1746 Stage::Type(TypeCheck::Int),
1747 Stage::Range {
1748 min: Some(0),
1749 max: Some(1_000_000),
1750 },
1751 ]);
1752 assert_eq!(
1753 run_pipeline(&p, &json!(500_000), &bag).await,
1754 FieldOutcome::Pass
1755 );
1756 match run_pipeline(&p, &json!(2_000_000), &bag).await {
1758 FieldOutcome::Deny {
1759 reason,
1760 stage_index,
1761 } => {
1762 assert!(reason.contains("outside"));
1763 assert_eq!(stage_index, 1);
1764 },
1765 other => panic!("expected Deny, got {:?}", other),
1766 }
1767 }
1768
1769 #[tokio::test]
1770 async fn pipeline_length_validator() {
1771 let mut bag = AttributeBag::new();
1772 let p = make_pipeline(vec![Stage::Length {
1773 min: None,
1774 max: Some(5),
1775 }]);
1776 assert_eq!(
1777 run_pipeline(&p, &json!("hi"), &bag).await,
1778 FieldOutcome::Pass
1779 );
1780 assert!(matches!(
1781 run_pipeline(&p, &json!("too long"), &bag).await,
1782 FieldOutcome::Deny { .. },
1783 ));
1784 }
1785
1786 #[tokio::test]
1787 async fn pipeline_enum_validator() {
1788 let mut bag = AttributeBag::new();
1789 let p = make_pipeline(vec![Stage::Enum {
1790 values: vec!["low".into(), "medium".into(), "high".into()],
1791 }]);
1792 assert_eq!(
1793 run_pipeline(&p, &json!("medium"), &bag).await,
1794 FieldOutcome::Pass
1795 );
1796 assert!(matches!(
1797 run_pipeline(&p, &json!("extreme"), &bag).await,
1798 FieldOutcome::Deny { .. },
1799 ));
1800 }
1801
1802 #[tokio::test]
1803 async fn pipeline_uuid_validator() {
1804 let mut bag = AttributeBag::new();
1805 let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]);
1806 assert_eq!(
1807 run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await,
1808 FieldOutcome::Pass,
1809 );
1810 assert!(matches!(
1811 run_pipeline(&p, &json!("not-a-uuid"), &bag).await,
1812 FieldOutcome::Deny { .. },
1813 ));
1814 }
1815
1816 #[tokio::test]
1817 async fn pipeline_hash_replaces_value() {
1818 let mut bag = AttributeBag::new();
1819 let p = make_pipeline(vec![Stage::Hash]);
1820 match run_pipeline(&p, &json!("secret"), &bag).await {
1821 FieldOutcome::Replace(v) => {
1822 let s = v.as_str().unwrap();
1823 assert!(s.starts_with("hash:"));
1824 assert_eq!(s.len(), "hash:".len() + 16);
1825 },
1826 other => panic!("expected Replace, got {:?}", other),
1827 }
1828 }
1829
1830 #[tokio::test]
1831 async fn pipeline_validate_named_denies_at_runtime() {
1832 let mut bag = AttributeBag::new();
1838 let p = make_pipeline(vec![
1839 Stage::Type(TypeCheck::Str),
1840 Stage::Validate {
1841 name: "ssn_format".into(),
1842 },
1843 Stage::Mask { keep_last: 4 },
1844 ]);
1845 match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1846 FieldOutcome::Deny {
1847 reason,
1848 stage_index,
1849 } => {
1850 assert_eq!(stage_index, 1, "validate stage is at index 1");
1851 assert!(
1852 reason.contains("not implemented"),
1853 "deny reason should explain that validate is unimplemented: {reason}",
1854 );
1855 assert!(
1856 reason.contains("regex") || reason.contains("plugin"),
1857 "deny reason should point at alternatives: {reason}",
1858 );
1859 },
1860 other => panic!("expected Deny on validate(...) stage, got {:?}", other),
1861 }
1862 }
1863
1864 #[tokio::test]
1865 async fn pipeline_validator_short_circuits_before_transform() {
1866 let mut bag = AttributeBag::new();
1868 let p = make_pipeline(vec![
1869 Stage::Type(TypeCheck::Int), Stage::Mask { keep_last: 4 },
1871 ]);
1872 match run_pipeline(&p, &json!("hello"), &bag).await {
1873 FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0),
1874 other => panic!("expected Deny at stage 0, got {:?}", other),
1875 }
1876 }
1877
1878 #[tokio::test]
1881 async fn pipeline_regex_match_passes() {
1882 let mut bag = AttributeBag::new();
1883 let p = make_pipeline(vec![Stage::Regex {
1884 pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
1885 }]);
1886 assert_eq!(
1887 run_pipeline(&p, &json!("123-45-6789"), &bag).await,
1888 FieldOutcome::Pass
1889 );
1890 }
1891
1892 #[tokio::test]
1893 async fn pipeline_regex_no_match_denies() {
1894 let mut bag = AttributeBag::new();
1895 let p = make_pipeline(vec![Stage::Regex {
1896 pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
1897 }]);
1898 match run_pipeline(&p, &json!("not an ssn"), &bag).await {
1899 FieldOutcome::Deny {
1900 reason,
1901 stage_index,
1902 } => {
1903 assert!(reason.contains("did not match"));
1904 assert_eq!(stage_index, 0);
1905 },
1906 other => panic!("expected Deny, got {:?}", other),
1907 }
1908 }
1909
1910 #[tokio::test]
1911 async fn pipeline_regex_invalid_pattern_denies() {
1912 let mut bag = AttributeBag::new();
1913 let p = make_pipeline(vec![Stage::Regex {
1914 pattern: "(unclosed".into(),
1915 }]);
1916 match run_pipeline(&p, &json!("anything"), &bag).await {
1917 FieldOutcome::Deny { reason, .. } => {
1918 assert!(reason.contains("invalid regex"));
1919 },
1920 other => panic!("expected Deny, got {:?}", other),
1921 }
1922 }
1923
1924 #[tokio::test]
1925 async fn pipeline_regex_non_string_denies() {
1926 let mut bag = AttributeBag::new();
1927 let p = make_pipeline(vec![Stage::Regex {
1928 pattern: r"^\d+$".into(),
1929 }]);
1930 match run_pipeline(&p, &json!(42), &bag).await {
1931 FieldOutcome::Deny { reason, .. } => {
1932 assert!(reason.contains("requires string"));
1933 },
1934 other => panic!("expected Deny on non-string regex input, got {:?}", other),
1935 }
1936 }
1937
1938 #[tokio::test]
1941 async fn pipeline_taint_records_event() {
1942 let mut bag = AttributeBag::new();
1943 let p = make_pipeline(vec![
1944 Stage::Type(TypeCheck::Str),
1945 Stage::Taint {
1946 label: "PII".into(),
1947 scopes: vec![TaintScope::Session],
1948 },
1949 Stage::Mask { keep_last: 4 },
1950 ]);
1951 let result = evaluate_pipeline(
1952 &p,
1953 &json!("123-45-6789"),
1954 &bag,
1955 &null_pipe_plugins(),
1956 "test_field",
1957 crate::step::DispatchPhase::Pre,
1958 )
1959 .await;
1960 assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
1961 assert_eq!(
1962 result.taints,
1963 vec![TaintEvent {
1964 label: "PII".into(),
1965 scopes: vec![TaintScope::Session],
1966 }]
1967 );
1968 }
1969
1970 #[tokio::test]
1971 async fn pipeline_scan_pii_detect_emits_taint() {
1972 let mut bag = AttributeBag::new();
1973 let p = make_pipeline(vec![Stage::Scan {
1974 kind: ScanKind::PiiDetect,
1975 }]);
1976 let result = evaluate_pipeline(
1977 &p,
1978 &json!("some text"),
1979 &bag,
1980 &null_pipe_plugins(),
1981 "test_field",
1982 crate::step::DispatchPhase::Pre,
1983 )
1984 .await;
1985 assert_eq!(result.outcome, FieldOutcome::Pass);
1987 assert_eq!(
1988 result.taints,
1989 vec![TaintEvent {
1990 label: "PII".into(),
1991 scopes: vec![TaintScope::Session],
1992 }]
1993 );
1994 }
1995
1996 #[tokio::test]
1997 async fn pipeline_scan_pii_redact_replaces_and_taints() {
1998 let mut bag = AttributeBag::new();
1999 let p = make_pipeline(vec![Stage::Scan {
2000 kind: ScanKind::PiiRedact,
2001 }]);
2002 let result = evaluate_pipeline(
2003 &p,
2004 &json!("123-45-6789"),
2005 &bag,
2006 &null_pipe_plugins(),
2007 "test_field",
2008 crate::step::DispatchPhase::Pre,
2009 )
2010 .await;
2011 assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]")));
2012 assert_eq!(result.taints.len(), 1);
2013 assert_eq!(result.taints[0].label, "PII");
2014 }
2015
2016 #[tokio::test]
2017 async fn pipeline_scan_injection_emits_injection_taint() {
2018 let mut bag = AttributeBag::new();
2019 let p = make_pipeline(vec![Stage::Scan {
2020 kind: ScanKind::InjectionScan,
2021 }]);
2022 let result = evaluate_pipeline(
2023 &p,
2024 &json!("user input"),
2025 &bag,
2026 &null_pipe_plugins(),
2027 "test_field",
2028 crate::step::DispatchPhase::Pre,
2029 )
2030 .await;
2031 assert_eq!(result.outcome, FieldOutcome::Pass);
2032 assert_eq!(result.taints[0].label, "injection");
2033 }
2034
2035 #[tokio::test]
2036 async fn pipeline_deny_does_not_accumulate_later_taints() {
2037 let mut bag = AttributeBag::new();
2040 let p = make_pipeline(vec![
2041 Stage::Taint {
2042 label: "before".into(),
2043 scopes: vec![TaintScope::Session],
2044 },
2045 Stage::Type(TypeCheck::Int), Stage::Taint {
2047 label: "after".into(),
2048 scopes: vec![TaintScope::Session],
2049 },
2050 ]);
2051 let result = evaluate_pipeline(
2052 &p,
2053 &json!("hello"),
2054 &bag,
2055 &null_pipe_plugins(),
2056 "test_field",
2057 crate::step::DispatchPhase::Pre,
2058 )
2059 .await;
2060 assert!(matches!(result.outcome, FieldOutcome::Deny { .. }));
2061 assert_eq!(
2062 result.taints,
2063 vec![TaintEvent {
2064 label: "before".into(),
2065 scopes: vec![TaintScope::Session],
2066 }]
2067 );
2068 }
2069
2070 struct PipePlugin {
2074 outcomes: std::collections::HashMap<String, PluginOutcome>,
2075 }
2076 #[async_trait]
2077 impl PluginInvoker for PipePlugin {
2078 async fn invoke(
2079 &self,
2080 name: &str,
2081 _bag: &AttributeBag,
2082 _invocation: PluginInvocation<'_>,
2083 ) -> Result<PluginOutcome, PluginError> {
2084 self.outcomes
2085 .get(name)
2086 .cloned()
2087 .ok_or_else(|| PluginError::NotFound(name.into()))
2088 }
2089 }
2090
2091 #[tokio::test]
2092 async fn pipeline_plugin_allow_continues() {
2093 let mut bag = AttributeBag::new();
2094 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2095 outcomes: std::collections::HashMap::from([(
2096 "noop".to_string(),
2097 PluginOutcome::allow(),
2098 )]),
2099 });
2100 let p = make_pipeline(vec![
2101 Stage::Type(TypeCheck::Str),
2102 Stage::Plugin {
2103 name: "noop".into(),
2104 },
2105 Stage::Mask { keep_last: 4 },
2106 ]);
2107 let result = evaluate_pipeline(
2108 &p,
2109 &json!("123-45-6789"),
2110 &bag,
2111 &plugins,
2112 "compensation",
2113 crate::step::DispatchPhase::Pre,
2114 )
2115 .await;
2116 assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
2117 assert!(result.taints.is_empty());
2118 }
2119
2120 #[tokio::test]
2121 async fn pipeline_plugin_can_replace_value() {
2122 let mut bag = AttributeBag::new();
2123 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2124 outcomes: std::collections::HashMap::from([(
2125 "scrubber".to_string(),
2126 PluginOutcome {
2127 decision: Decision::Allow,
2128 taints: vec![TaintEvent {
2129 label: "PII".to_string(),
2130 scopes: vec![TaintScope::Session],
2131 }],
2132 modified_value: Some(json!("***scrubbed***")),
2133 },
2134 )]),
2135 });
2136 let p = make_pipeline(vec![Stage::Plugin {
2137 name: "scrubber".into(),
2138 }]);
2139 let result = evaluate_pipeline(
2140 &p,
2141 &json!("sensitive data"),
2142 &bag,
2143 &plugins,
2144 "notes",
2145 crate::step::DispatchPhase::Pre,
2146 )
2147 .await;
2148 assert_eq!(
2149 result.outcome,
2150 FieldOutcome::Replace(json!("***scrubbed***"))
2151 );
2152 assert_eq!(
2153 result.taints,
2154 vec![TaintEvent {
2155 label: "PII".into(),
2156 scopes: vec![TaintScope::Session],
2157 }]
2158 );
2159 }
2160
2161 #[tokio::test]
2162 async fn pipeline_plugin_deny_halts() {
2163 let mut bag = AttributeBag::new();
2164 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2165 outcomes: std::collections::HashMap::from([(
2166 "guard".to_string(),
2167 PluginOutcome {
2168 decision: Decision::Deny {
2169 reason: Some("policy violation".into()),
2170 rule_source: "guard".into(),
2171 },
2172 taints: vec![],
2173 modified_value: None,
2174 },
2175 )]),
2176 });
2177 let p = make_pipeline(vec![
2178 Stage::Plugin {
2179 name: "guard".into(),
2180 },
2181 Stage::Mask { keep_last: 4 },
2183 ]);
2184 let result = evaluate_pipeline(
2185 &p,
2186 &json!("data"),
2187 &bag,
2188 &plugins,
2189 "payload",
2190 crate::step::DispatchPhase::Pre,
2191 )
2192 .await;
2193 match result.outcome {
2194 FieldOutcome::Deny {
2195 reason,
2196 stage_index,
2197 } => {
2198 assert_eq!(reason, "policy violation");
2199 assert_eq!(stage_index, 0);
2200 },
2201 other => panic!("expected Deny, got {:?}", other),
2202 }
2203 }
2204
2205 #[tokio::test]
2206 async fn pipeline_plugin_missing_fails_closed() {
2207 let mut bag = AttributeBag::new();
2208 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2209 outcomes: Default::default(),
2210 });
2211 let p = make_pipeline(vec![Stage::Plugin {
2212 name: "missing".into(),
2213 }]);
2214 let result = evaluate_pipeline(
2215 &p,
2216 &json!("data"),
2217 &bag,
2218 &plugins,
2219 "payload",
2220 crate::step::DispatchPhase::Pre,
2221 )
2222 .await;
2223 match result.outcome {
2224 FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")),
2225 other => panic!("expected Deny on missing plugin, got {:?}", other),
2226 }
2227 }
2228
2229 #[test]
2234 fn exists_distinguishes_missing_from_falsy() {
2235 let mut bag = AttributeBag::new();
2236 bag.set("args.flag", false);
2237 assert!(!eval_condition(
2239 &Condition::IsTrue {
2240 key: "args.flag".into()
2241 },
2242 &bag
2243 ));
2244 assert!(eval_condition(
2245 &Condition::Exists {
2246 key: "args.flag".into()
2247 },
2248 &bag
2249 ));
2250 assert!(!eval_condition(
2252 &Condition::Exists {
2253 key: "args.nonexistent".into()
2254 },
2255 &bag
2256 ));
2257 }
2258
2259 #[test]
2260 fn in_set_member_and_non_member() {
2261 let mut bag = AttributeBag::new();
2262 bag.set("subject.type", "user");
2263 bag.set(
2264 "allowed_types",
2265 std::collections::HashSet::from(["user".to_string(), "service".to_string()]),
2266 );
2267
2268 assert!(eval_condition(
2269 &Condition::InSet {
2270 value_key: "subject.type".into(),
2271 set_key: "allowed_types".into(),
2272 negate: false,
2273 },
2274 &bag
2275 ));
2276
2277 bag.set("subject.type", "agent");
2278 assert!(!eval_condition(
2279 &Condition::InSet {
2280 value_key: "subject.type".into(),
2281 set_key: "allowed_types".into(),
2282 negate: false,
2283 },
2284 &bag
2285 ));
2286 }
2287
2288 #[test]
2289 fn in_set_negate() {
2290 let mut bag = AttributeBag::new();
2291 bag.set("subject.type", "agent");
2292 bag.set(
2293 "blocked_types",
2294 std::collections::HashSet::from(["service".to_string()]),
2295 );
2296
2297 assert!(eval_condition(
2299 &Condition::InSet {
2300 value_key: "subject.type".into(),
2301 set_key: "blocked_types".into(),
2302 negate: true,
2303 },
2304 &bag
2305 ));
2306 }
2307
2308 #[test]
2309 fn in_set_missing_keys_resolve_to_false() {
2310 let mut bag = AttributeBag::new();
2311 assert!(!eval_condition(
2314 &Condition::InSet {
2315 value_key: "x".into(),
2316 set_key: "y".into(),
2317 negate: false,
2318 },
2319 &bag
2320 ));
2321 assert!(eval_condition(
2322 &Condition::InSet {
2323 value_key: "x".into(),
2324 set_key: "y".into(),
2325 negate: true,
2326 },
2327 &bag
2328 ));
2329 }
2330
2331 #[test]
2332 fn always_evaluates_true() {
2333 let mut bag = AttributeBag::new();
2334 assert!(eval_expression(&Expression::Always, &bag));
2335 }
2336
2337 #[test]
2338 fn always_rule_unconditional_deny() {
2339 let mut bag = AttributeBag::new();
2340 let r = Rule {
2341 condition: Expression::Always,
2342 effects: vec![Effect::Deny {
2343 reason: Some("unconditional".into()),
2344 code: None,
2345 }],
2346 source: "test".into(),
2347 };
2348 match evaluate_rules(&[r], &bag) {
2349 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")),
2350 d => panic!("expected Deny, got {:?}", d),
2351 }
2352 }
2353
2354 use crate::step::{
2359 PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation,
2360 PluginInvoker, PluginOutcome,
2361 };
2362 use async_trait::async_trait;
2363
2364 struct FakePdp {
2368 decision: Decision,
2369 }
2370 #[async_trait]
2371 impl PdpResolver for FakePdp {
2372 fn dialect(&self) -> PdpDialect {
2373 PdpDialect::Cedar
2374 }
2375 async fn evaluate(
2376 &self,
2377 _call: &PdpCall,
2378 _bag: &AttributeBag,
2379 ) -> Result<PdpDecision, PdpError> {
2380 Ok(PdpDecision {
2381 decision: self.decision.clone(),
2382 diagnostics: vec![],
2383 })
2384 }
2385 }
2386
2387 struct ErroringPdp;
2389 #[async_trait]
2390 impl PdpResolver for ErroringPdp {
2391 fn dialect(&self) -> PdpDialect {
2392 PdpDialect::Cedar
2393 }
2394 async fn evaluate(
2395 &self,
2396 _call: &PdpCall,
2397 _bag: &AttributeBag,
2398 ) -> Result<PdpDecision, PdpError> {
2399 Err(PdpError::Dispatch("simulated PDP outage".into()))
2400 }
2401 }
2402
2403 struct FakePlugin {
2405 decisions: std::collections::HashMap<String, Decision>,
2406 }
2407 #[async_trait]
2408 impl PluginInvoker for FakePlugin {
2409 async fn invoke(
2410 &self,
2411 name: &str,
2412 _bag: &AttributeBag,
2413 _invocation: PluginInvocation<'_>,
2414 ) -> Result<PluginOutcome, PluginError> {
2415 match self.decisions.get(name) {
2416 Some(d) => Ok(PluginOutcome {
2417 decision: d.clone(),
2418 taints: vec![],
2419 modified_value: None,
2420 }),
2421 None => Err(PluginError::NotFound(name.into())),
2422 }
2423 }
2424 }
2425
2426 struct NullPlugins;
2428 #[async_trait]
2429 impl PluginInvoker for NullPlugins {
2430 async fn invoke(
2431 &self,
2432 name: &str,
2433 _bag: &AttributeBag,
2434 _invocation: PluginInvocation<'_>,
2435 ) -> Result<PluginOutcome, PluginError> {
2436 Err(PluginError::NotFound(name.into()))
2437 }
2438 }
2439
2440 fn pdp_step(decision_diagnostic_label: &str) -> Effect {
2441 Effect::Pdp {
2442 call: PdpCall {
2443 dialect: PdpDialect::Cedar,
2444 args: serde_yaml::Value::String(decision_diagnostic_label.into()),
2445 },
2446 on_deny: vec![],
2447 on_allow: vec![],
2448 }
2449 }
2450
2451 #[tokio::test]
2452 async fn steps_rule_only_path() {
2453 let mut bag = AttributeBag::new();
2454 let steps = vec![Effect::When {
2455 condition: Expression::Always,
2456 body: vec![Effect::Allow],
2457 source: "test".into(),
2458 }];
2459 let r = evaluate_effects(
2460 &steps,
2461 &mut bag,
2462 &(Arc::new(FakePdp {
2463 decision: Decision::Allow,
2464 }) as Arc<dyn PdpResolver>),
2465 &null_plugins(),
2466 &noop_delegations(),
2467 crate::step::DispatchPhase::Pre,
2468 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2469 )
2470 .await;
2471 assert_eq!(r.decision, Decision::Allow);
2472 }
2473
2474 #[tokio::test]
2475 async fn pdp_allow_continues() {
2476 let mut bag = AttributeBag::new();
2477 let steps = vec![pdp_step("dummy")];
2478 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2479 decision: Decision::Allow,
2480 });
2481 assert_eq!(
2482 evaluate_effects(
2483 &steps,
2484 &mut bag,
2485 &pdp,
2486 &null_plugins(),
2487 &noop_delegations(),
2488 crate::step::DispatchPhase::Pre,
2489 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
2490 )
2491 .await
2492 .decision,
2493 Decision::Allow,
2494 );
2495 }
2496
2497 #[tokio::test]
2498 async fn pdp_deny_returns_deny() {
2499 let mut bag = AttributeBag::new();
2500 let steps = vec![pdp_step("dummy")];
2501 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2502 decision: Decision::Deny {
2503 reason: Some("forbidden".into()),
2504 rule_source: "pdp".into(),
2505 },
2506 });
2507 match evaluate_effects(
2508 &steps,
2509 &mut bag,
2510 &pdp,
2511 &null_plugins(),
2512 &noop_delegations(),
2513 crate::step::DispatchPhase::Pre,
2514 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2515 )
2516 .await
2517 .decision
2518 {
2519 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")),
2520 d => panic!("expected Deny, got {:?}", d),
2521 }
2522 }
2523
2524 #[tokio::test]
2525 async fn pdp_on_deny_reaction_can_override_reason() {
2526 let mut bag = AttributeBag::new();
2529 let steps = vec![Effect::Pdp {
2530 call: PdpCall {
2531 dialect: PdpDialect::Cedar,
2532 args: serde_yaml::Value::Null,
2533 },
2534 on_deny: vec![Effect::When {
2535 condition: Expression::Always,
2536 body: vec![Effect::Deny {
2537 reason: Some("reaction took over".into()),
2538 code: None,
2539 }],
2540 source: "on_deny[0]".into(),
2541 }],
2542 on_allow: vec![],
2543 }];
2544 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2545 decision: Decision::Deny {
2546 reason: Some("pdp original".into()),
2547 rule_source: "p".into(),
2548 },
2549 });
2550 match evaluate_effects(
2551 &steps,
2552 &mut bag,
2553 &pdp,
2554 &null_plugins(),
2555 &noop_delegations(),
2556 crate::step::DispatchPhase::Pre,
2557 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2558 )
2559 .await
2560 .decision
2561 {
2562 Decision::Deny {
2563 reason,
2564 rule_source,
2565 } => {
2566 assert_eq!(reason.as_deref(), Some("reaction took over"));
2567 assert_eq!(rule_source, "on_deny[0]");
2568 },
2569 d => panic!("expected Deny, got {:?}", d),
2570 }
2571 }
2572
2573 #[tokio::test]
2574 async fn pdp_on_allow_can_deny() {
2575 let mut bag = AttributeBag::new();
2578 let steps = vec![Effect::Pdp {
2579 call: PdpCall {
2580 dialect: PdpDialect::Cedar,
2581 args: serde_yaml::Value::Null,
2582 },
2583 on_deny: vec![],
2584 on_allow: vec![Effect::When {
2585 condition: Expression::Always,
2586 body: vec![Effect::Deny {
2587 reason: Some("reaction veto".into()),
2588 code: None,
2589 }],
2590 source: "on_allow[0]".into(),
2591 }],
2592 }];
2593 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2594 decision: Decision::Allow,
2595 });
2596 match evaluate_effects(
2597 &steps,
2598 &mut bag,
2599 &pdp,
2600 &null_plugins(),
2601 &noop_delegations(),
2602 crate::step::DispatchPhase::Pre,
2603 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2604 )
2605 .await
2606 .decision
2607 {
2608 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")),
2609 d => panic!("expected Deny, got {:?}", d),
2610 }
2611 }
2612
2613 #[tokio::test]
2614 async fn pdp_error_is_fail_closed() {
2615 let mut bag = AttributeBag::new();
2616 let steps = vec![pdp_step("dummy")];
2617 match evaluate_effects(
2618 &steps,
2619 &mut bag,
2620 &(Arc::new(ErroringPdp) as Arc<dyn PdpResolver>),
2621 &null_plugins(),
2622 &noop_delegations(),
2623 crate::step::DispatchPhase::Pre,
2624 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2625 )
2626 .await
2627 .decision
2628 {
2629 Decision::Deny { reason, .. } => {
2630 assert!(reason.unwrap().contains("PDP error"));
2631 },
2632 d => panic!("expected Deny on PDP error, got {:?}", d),
2633 }
2634 }
2635
2636 #[tokio::test]
2637 async fn plugin_allow_continues_deny_halts() {
2638 let mut bag = AttributeBag::new();
2639 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
2640 decisions: std::collections::HashMap::from([
2641 ("ok_plugin".to_string(), Decision::Allow),
2642 (
2643 "blocking_plugin".to_string(),
2644 Decision::Deny {
2645 reason: Some("rate limit hit".into()),
2646 rule_source: "plugin".into(),
2647 },
2648 ),
2649 ]),
2650 });
2651
2652 let allow_only = vec![Effect::Plugin {
2653 name: "ok_plugin".into(),
2654 }];
2655 assert_eq!(
2656 evaluate_effects(
2657 &allow_only,
2658 &mut bag,
2659 &(Arc::new(FakePdp {
2660 decision: Decision::Allow
2661 }) as Arc<dyn PdpResolver>),
2662 &plugins,
2663 &noop_delegations(),
2664 crate::step::DispatchPhase::Pre,
2665 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
2666 )
2667 .await
2668 .decision,
2669 Decision::Allow,
2670 );
2671
2672 let with_deny = vec![
2673 Effect::Plugin {
2674 name: "ok_plugin".into(),
2675 },
2676 Effect::Plugin {
2677 name: "blocking_plugin".into(),
2678 },
2679 ];
2680 match evaluate_effects(
2681 &with_deny,
2682 &mut bag,
2683 &(Arc::new(FakePdp {
2684 decision: Decision::Allow,
2685 }) as Arc<dyn PdpResolver>),
2686 &plugins,
2687 &noop_delegations(),
2688 crate::step::DispatchPhase::Pre,
2689 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2690 )
2691 .await
2692 .decision
2693 {
2694 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")),
2695 d => panic!("expected Deny from blocking_plugin, got {:?}", d),
2696 }
2697 }
2698
2699 #[tokio::test]
2700 async fn plugin_error_is_fail_closed() {
2701 let mut bag = AttributeBag::new();
2702 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
2703 decisions: Default::default(),
2704 });
2705 let steps = vec![Effect::Plugin {
2706 name: "missing".into(),
2707 }];
2708 match evaluate_effects(
2709 &steps,
2710 &mut bag,
2711 &(Arc::new(FakePdp {
2712 decision: Decision::Allow,
2713 }) as Arc<dyn PdpResolver>),
2714 &plugins,
2715 &noop_delegations(),
2716 crate::step::DispatchPhase::Pre,
2717 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2718 )
2719 .await
2720 .decision
2721 {
2722 Decision::Deny {
2723 reason,
2724 rule_source,
2725 } => {
2726 assert!(reason.unwrap().contains("missing"));
2727 assert!(rule_source.contains("missing"));
2728 },
2729 d => panic!("expected Deny, got {:?}", d),
2730 }
2731 }
2732
2733 #[tokio::test]
2734 async fn taint_step_always_continues_and_accumulates() {
2735 let mut bag = AttributeBag::new();
2736 let steps = vec![
2737 Effect::Taint {
2738 label: "PII".into(),
2739 scopes: vec![crate::pipeline::TaintScope::Session],
2740 },
2741 Effect::When {
2743 condition: Expression::Always,
2744 body: vec![Effect::Deny {
2745 reason: Some("after taint".into()),
2746 code: None,
2747 }],
2748 source: "p[1]".into(),
2749 },
2750 ];
2751 let eval = evaluate_effects(
2752 &steps,
2753 &mut bag,
2754 &(Arc::new(FakePdp {
2755 decision: Decision::Allow,
2756 }) as Arc<dyn PdpResolver>),
2757 &null_plugins(),
2758 &noop_delegations(),
2759 crate::step::DispatchPhase::Pre,
2760 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2761 )
2762 .await;
2763 match eval.decision {
2764 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")),
2765 d => panic!("expected Deny from rule after Taint, got {:?}", d),
2766 }
2767 assert_eq!(eval.taints.len(), 1);
2771 assert_eq!(eval.taints[0].label, "PII");
2772 assert_eq!(
2773 eval.taints[0].scopes,
2774 vec![crate::pipeline::TaintScope::Session]
2775 );
2776 }
2777
2778 #[tokio::test]
2781 async fn field_op_in_do_redacts_args_during_pre_phase() {
2782 let mut bag = AttributeBag::new();
2786 let stages = vec![Stage::Redact { condition: None }];
2788 let rule = Rule {
2789 condition: Expression::Always,
2790 effects: vec![Effect::FieldOp {
2791 path: "args.ssn".into(),
2792 stages,
2793 }],
2794 source: "demo.policy[0]".into(),
2795 };
2796 let steps = vec![Effect::from(rule)];
2797 let mut payload = crate::route::RoutePayload::new(json!({
2798 "ssn": "123-45-6789",
2799 "name": "Jane",
2800 }));
2801
2802 let eval = evaluate_effects(
2803 &steps,
2804 &mut bag,
2805 &(Arc::new(FakePdp {
2806 decision: Decision::Allow,
2807 }) as Arc<dyn PdpResolver>),
2808 &null_plugins(),
2809 &noop_delegations(),
2810 crate::step::DispatchPhase::Pre,
2811 &mut payload,
2812 )
2813 .await;
2814
2815 assert_eq!(eval.decision, Decision::Allow);
2816 assert!(eval.args_modified, "FieldOp should flag args_modified");
2817 assert_eq!(
2820 payload.args.get("ssn").and_then(|v| v.as_str()),
2821 Some("[REDACTED]")
2822 );
2823 assert_eq!(
2825 payload.args.get("name").and_then(|v| v.as_str()),
2826 Some("Jane")
2827 );
2828 }
2829
2830 #[tokio::test]
2831 async fn field_op_targeting_result_in_pre_phase_is_skipped() {
2832 let mut bag = AttributeBag::new();
2836 let stages = vec![Stage::Redact { condition: None }];
2837 let rule = Rule {
2838 condition: Expression::Always,
2839 effects: vec![Effect::FieldOp {
2840 path: "result.ssn".into(),
2841 stages,
2842 }],
2843 source: "demo.policy[0]".into(),
2844 };
2845 let mut payload = crate::route::RoutePayload::new(json!({}));
2846 let eval = evaluate_effects(
2847 &vec![Effect::from(rule)],
2848 &mut bag,
2849 &(Arc::new(FakePdp {
2850 decision: Decision::Allow,
2851 }) as Arc<dyn PdpResolver>),
2852 &null_plugins(),
2853 &noop_delegations(),
2854 crate::step::DispatchPhase::Pre,
2855 &mut payload,
2856 )
2857 .await;
2858 assert_eq!(eval.decision, Decision::Allow);
2859 assert!(!eval.args_modified);
2860 assert!(!eval.result_modified);
2861 }
2862
2863 #[tokio::test]
2864 async fn field_op_with_invalid_path_denies() {
2865 let mut bag = AttributeBag::new();
2869 let stages = vec![Stage::Redact { condition: None }];
2870 let rule = Rule {
2871 condition: Expression::Always,
2872 effects: vec![Effect::FieldOp {
2873 path: "ssn".into(), stages,
2875 }],
2876 source: "demo.policy[0]".into(),
2877 };
2878 let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"}));
2879 let eval = evaluate_effects(
2880 &vec![Effect::from(rule)],
2881 &mut bag,
2882 &(Arc::new(FakePdp {
2883 decision: Decision::Allow,
2884 }) as Arc<dyn PdpResolver>),
2885 &null_plugins(),
2886 &noop_delegations(),
2887 crate::step::DispatchPhase::Pre,
2888 &mut payload,
2889 )
2890 .await;
2891 match eval.decision {
2892 Decision::Deny {
2893 reason,
2894 rule_source,
2895 } => {
2896 assert!(reason.unwrap_or_default().contains("must start with"));
2897 assert_eq!(rule_source, "demo.policy[0]");
2898 },
2899 other => panic!("expected Deny, got {:?}", other),
2900 }
2901 }
2902
2903 #[tokio::test]
2906 async fn sequential_runs_effects_in_order_until_deny() {
2907 let mut bag = AttributeBag::new();
2911 let mut payload = crate::route::RoutePayload::new(json!({}));
2912
2913 let rule = Rule {
2914 condition: Expression::Always,
2915 effects: vec![Effect::Sequential(vec![
2916 Effect::Allow,
2917 Effect::Deny {
2918 reason: Some("blocked by sequential".into()),
2919 code: Some("seq.test".into()),
2920 },
2921 Effect::Allow, ])],
2923 source: "test.policy[0]".into(),
2924 };
2925
2926 let eval = evaluate_effects(
2927 &vec![Effect::from(rule)],
2928 &mut bag,
2929 &(Arc::new(FakePdp {
2930 decision: Decision::Allow,
2931 }) as Arc<dyn PdpResolver>),
2932 &null_plugins(),
2933 &noop_delegations(),
2934 crate::step::DispatchPhase::Pre,
2935 &mut payload,
2936 )
2937 .await;
2938
2939 match eval.decision {
2940 Decision::Deny {
2941 reason,
2942 rule_source,
2943 } => {
2944 assert_eq!(reason.as_deref(), Some("blocked by sequential"));
2945 assert_eq!(rule_source, "seq.test");
2948 },
2949 other => panic!("expected Deny, got {:?}", other),
2950 }
2951 }
2952
2953 #[tokio::test]
2954 async fn parallel_allows_when_no_branch_denies() {
2955 let mut bag = AttributeBag::new();
2957 let mut payload = crate::route::RoutePayload::new(json!({}));
2958
2959 let rule = Rule {
2960 condition: Expression::Always,
2961 effects: vec![Effect::Parallel(vec![
2962 Effect::Allow,
2963 Effect::Taint {
2964 label: "audit_branch".into(),
2965 scopes: vec![crate::pipeline::TaintScope::Session],
2966 },
2967 ])],
2968 source: "test.policy[0]".into(),
2969 };
2970
2971 let eval = evaluate_effects(
2972 &vec![Effect::from(rule)],
2973 &mut bag,
2974 &(Arc::new(FakePdp {
2975 decision: Decision::Allow,
2976 }) as Arc<dyn PdpResolver>),
2977 &null_plugins(),
2978 &noop_delegations(),
2979 crate::step::DispatchPhase::Pre,
2980 &mut payload,
2981 )
2982 .await;
2983
2984 assert_eq!(eval.decision, Decision::Allow);
2985 assert_eq!(eval.taints.len(), 1);
2987 assert_eq!(eval.taints[0].label, "audit_branch");
2988 }
2989
2990 #[tokio::test]
2991 async fn parallel_denies_when_any_branch_denies() {
2992 let mut bag = AttributeBag::new();
2994 let mut payload = crate::route::RoutePayload::new(json!({}));
2995
2996 let rule = Rule {
2997 condition: Expression::Always,
2998 effects: vec![Effect::Parallel(vec![
2999 Effect::Allow,
3000 Effect::Deny {
3001 reason: Some("branch 1 denied".into()),
3002 code: None,
3003 },
3004 ])],
3005 source: "test.policy[0]".into(),
3006 };
3007
3008 let eval = evaluate_effects(
3009 &vec![Effect::from(rule)],
3010 &mut bag,
3011 &(Arc::new(FakePdp {
3012 decision: Decision::Allow,
3013 }) as Arc<dyn PdpResolver>),
3014 &null_plugins(),
3015 &noop_delegations(),
3016 crate::step::DispatchPhase::Pre,
3017 &mut payload,
3018 )
3019 .await;
3020
3021 match eval.decision {
3022 Decision::Deny { reason, .. } => {
3023 assert_eq!(reason.as_deref(), Some("branch 1 denied"));
3024 },
3025 other => panic!("expected Deny, got {:?}", other),
3026 }
3027 }
3028
3029 #[tokio::test]
3030 async fn parallel_picks_first_index_halt_not_first_to_complete() {
3031 let mut bag = AttributeBag::new();
3035 let mut payload = crate::route::RoutePayload::new(json!({}));
3036
3037 let rule = Rule {
3038 condition: Expression::Always,
3039 effects: vec![Effect::Parallel(vec![
3040 Effect::Deny {
3041 reason: Some("idx-0".into()),
3042 code: None,
3043 },
3044 Effect::Deny {
3045 reason: Some("idx-1".into()),
3046 code: None,
3047 },
3048 ])],
3049 source: "test.policy[0]".into(),
3050 };
3051
3052 let eval = evaluate_effects(
3053 &vec![Effect::from(rule)],
3054 &mut bag,
3055 &(Arc::new(FakePdp {
3056 decision: Decision::Allow,
3057 }) as Arc<dyn PdpResolver>),
3058 &null_plugins(),
3059 &noop_delegations(),
3060 crate::step::DispatchPhase::Pre,
3061 &mut payload,
3062 )
3063 .await;
3064
3065 match eval.decision {
3066 Decision::Deny { reason, .. } => {
3067 assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins");
3068 },
3069 other => panic!("expected Deny, got {:?}", other),
3070 }
3071 }
3072}