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 = match coerce_f64_attr(attr) {
153 Some(a) => a,
154 None => return false,
155 };
156 let b = match coerce_f64_lit(lit) {
157 Some(b) => b,
158 None => return false,
159 };
160 match op {
161 CompareOp::Gt => a > b,
162 CompareOp::GtEq => a >= b,
163 CompareOp::Lt => a < b,
164 CompareOp::LtEq => a <= b,
165 _ => unreachable!("numeric_compare called with non-numeric op"),
166 }
167}
168
169fn coerce_f64_attr(attr: &AttributeValue) -> Option<f64> {
173 match attr {
174 AttributeValue::Int(a) => Some(*a as f64),
175 AttributeValue::Float(a) => Some(*a),
176 AttributeValue::String(s) => s.trim().parse::<f64>().ok(),
177 _ => None,
178 }
179}
180
181fn coerce_f64_lit(lit: &Literal) -> Option<f64> {
184 match lit {
185 Literal::Int(b) => Some(*b as f64),
186 Literal::Float(b) => Some(*b),
187 Literal::String(s) => s.trim().parse::<f64>().ok(),
188 _ => None,
189 }
190}
191
192fn looks_like_attribute_ref(s: &str) -> bool {
203 s.contains('.')
204 && s.starts_with(|c: char| c.is_ascii_alphabetic())
205 && s.chars()
206 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
207}
208
209#[allow(clippy::too_many_arguments)]
240pub async fn evaluate_effects(
241 effects: &[Effect],
242 bag: &mut AttributeBag,
243 pdp: &Arc<dyn PdpResolver>,
244 plugins: &Arc<dyn PluginInvoker>,
245 delegations: &Arc<dyn crate::step::DelegationInvoker>,
246 elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
247 phase: crate::step::DispatchPhase,
248 payload: &mut crate::route::RoutePayload,
249) -> StepsEvaluation {
250 let mut taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
251 let mut args_modified = false;
252 let mut result_modified = false;
253 let mut pending: Option<crate::step::PendingElicitation> = None;
254 for effect in effects {
255 let fallback_source = match effect {
259 Effect::When { source, .. } => source.as_str(),
260 _ => "",
261 };
262 match Box::pin(dispatch_effect(
263 effect,
264 fallback_source,
265 bag,
266 pdp,
267 plugins,
268 delegations,
269 elicitations,
270 phase,
271 &mut taints,
272 &mut args_modified,
273 &mut result_modified,
274 payload,
275 ))
276 .await
277 {
278 EffectOutcome::Continue => {},
279 EffectOutcome::Halt(decision) => {
280 return StepsEvaluation::deny(decision, taints, args_modified, result_modified);
281 },
282 EffectOutcome::Pending(bundle) => {
283 pending = Some(bundle);
287 break;
288 },
289 }
290 }
291 StepsEvaluation {
292 decision: Decision::Allow,
293 taints,
294 args_modified,
295 result_modified,
296 pending,
297 }
298}
299
300#[derive(Debug, Clone)]
311pub struct StepsEvaluation {
312 pub decision: Decision,
313 pub taints: Vec<crate::pipeline::TaintEvent>,
314 pub args_modified: bool,
315 pub result_modified: bool,
316 pub pending: Option<crate::step::PendingElicitation>,
321}
322
323impl StepsEvaluation {
324 fn deny(
325 d: Decision,
326 taints: Vec<crate::pipeline::TaintEvent>,
327 args_modified: bool,
328 result_modified: bool,
329 ) -> Self {
330 Self {
331 decision: d,
332 taints,
333 args_modified,
334 result_modified,
335 pending: None,
336 }
337 }
338}
339
340enum EffectOutcome {
346 Continue,
349 Halt(Decision),
352 Pending(crate::step::PendingElicitation),
359}
360
361#[allow(clippy::too_many_arguments)]
373async fn dispatch_effect(
374 effect: &Effect,
375 fallback_source: &str,
376 bag: &mut AttributeBag,
377 pdp: &Arc<dyn PdpResolver>,
378 plugins: &Arc<dyn PluginInvoker>,
379 delegations: &Arc<dyn crate::step::DelegationInvoker>,
380 elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
381 phase: crate::step::DispatchPhase,
382 taints: &mut Vec<crate::pipeline::TaintEvent>,
383 args_modified: &mut bool,
384 result_modified: &mut bool,
385 payload: &mut crate::route::RoutePayload,
386) -> EffectOutcome {
387 match effect {
388 Effect::Allow => EffectOutcome::Continue,
389
390 Effect::Deny { reason, code } => {
391 let rule_source = code.clone().unwrap_or_else(|| fallback_source.to_string());
396 EffectOutcome::Halt(Decision::Deny {
397 reason: reason.clone(),
398 rule_source,
399 })
400 },
401
402 Effect::Plugin { name } => {
403 match plugins
404 .invoke(name, bag, PluginInvocation::Step { phase })
405 .await
406 {
407 Ok(outcome) => {
408 taints.extend(outcome.taints);
411 match outcome.decision {
412 Decision::Allow => EffectOutcome::Continue,
413 deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny),
414 }
415 },
416 Err(e) => EffectOutcome::Halt(Decision::Deny {
417 reason: Some(format!("plugin `{}` error: {}", name, e)),
418 rule_source: format!("plugin:{}", name),
419 }),
420 }
421 },
422
423 Effect::Delegate(delegate_step) => {
424 match delegations.delegate(delegate_step).await {
425 Ok(outcome) => match &outcome.decision {
426 Decision::Allow => {
427 use crate::attributes::AttributeValue;
432 use crate::step::delegation_bag_keys as bk;
433
434 bag.set(bk::GRANTED, AttributeValue::Bool(true));
435 if !outcome.granted_permissions.is_empty() {
436 let set: std::collections::HashSet<String> =
437 outcome.granted_permissions.iter().cloned().collect();
438 bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set));
439 }
440 if let Some(aud) = &outcome.granted_audience {
441 bag.set(bk::GRANTED_AUDIENCE, aud.clone());
442 }
443 if let Some(exp) = &outcome.granted_expires_at {
444 bag.set(bk::GRANTED_EXPIRES_AT, exp.clone());
445 }
446 EffectOutcome::Continue
447 },
448 Decision::Deny { .. } => {
449 let on_error = delegate_step
454 .on_error
455 .as_deref()
456 .unwrap_or("deny")
457 .to_ascii_lowercase();
458 if on_error == "continue" {
459 EffectOutcome::Continue
460 } else {
461 EffectOutcome::Halt(outcome.decision)
462 }
463 },
464 },
465 Err(e) => {
466 let on_error = delegate_step
469 .on_error
470 .as_deref()
471 .unwrap_or("deny")
472 .to_ascii_lowercase();
473 if on_error == "continue" {
474 EffectOutcome::Continue
475 } else {
476 EffectOutcome::Halt(Decision::Deny {
477 reason: Some(format!(
478 "delegate `{}` error: {}",
479 delegate_step.plugin_name, e
480 )),
481 rule_source: delegate_step.source.clone(),
482 })
483 }
484 },
485 }
486 },
487
488 Effect::Elicit(elicit_step) => dispatch_elicitation(elicit_step, bag, elicitations).await,
489
490 Effect::Taint { label, scopes } => {
491 taints.push(crate::pipeline::TaintEvent {
497 label: label.clone(),
498 scopes: scopes.clone(),
499 });
500 EffectOutcome::Continue
501 },
502
503 Effect::FieldOp { path, stages } => {
504 dispatch_field_op(
505 path,
506 stages,
507 fallback_source,
508 bag,
509 plugins,
510 phase,
511 taints,
512 args_modified,
513 result_modified,
514 payload,
515 )
516 .await
517 },
518
519 Effect::Sequential(effects) => {
520 for inner in effects {
525 match Box::pin(dispatch_effect(
526 inner,
527 fallback_source,
528 bag,
529 pdp,
530 plugins,
531 delegations,
532 elicitations,
533 phase,
534 taints,
535 args_modified,
536 result_modified,
537 payload,
538 ))
539 .await
540 {
541 EffectOutcome::Continue => continue,
542 other => return other,
544 }
545 }
546 EffectOutcome::Continue
547 },
548
549 Effect::Parallel(effects) => {
550 dispatch_parallel(
555 effects,
556 fallback_source,
557 bag,
558 pdp,
559 plugins,
560 delegations,
561 elicitations,
562 phase,
563 taints,
564 payload,
565 )
566 .await
567 },
568
569 Effect::When {
570 condition,
571 body,
572 source,
573 } => {
574 if !eval_expression(condition, bag) {
578 return EffectOutcome::Continue;
579 }
580 for inner in body {
581 match Box::pin(dispatch_effect(
582 inner,
583 source,
584 bag,
585 pdp,
586 plugins,
587 delegations,
588 elicitations,
589 phase,
590 taints,
591 args_modified,
592 result_modified,
593 payload,
594 ))
595 .await
596 {
597 EffectOutcome::Continue => continue,
598 other => return other,
600 }
601 }
602 EffectOutcome::Continue
603 },
604
605 Effect::Pdp {
606 call,
607 on_allow,
608 on_deny,
609 } => {
610 match pdp.evaluate(call, bag).await {
613 Ok(pdp_result) => match pdp_result.decision {
614 Decision::Allow => {
615 for inner in on_allow {
618 match Box::pin(dispatch_effect(
619 inner,
620 fallback_source,
621 bag,
622 pdp,
623 plugins,
624 delegations,
625 elicitations,
626 phase,
627 taints,
628 args_modified,
629 result_modified,
630 payload,
631 ))
632 .await
633 {
634 EffectOutcome::Continue => continue,
635 other => return other,
637 }
638 }
639 EffectOutcome::Continue
640 },
641 deny @ Decision::Deny { .. } => {
642 for inner in on_deny {
647 match Box::pin(dispatch_effect(
648 inner,
649 fallback_source,
650 bag,
651 pdp,
652 plugins,
653 delegations,
654 elicitations,
655 phase,
656 taints,
657 args_modified,
658 result_modified,
659 payload,
660 ))
661 .await
662 {
663 EffectOutcome::Continue => {},
664 other => return other,
667 }
668 }
669 EffectOutcome::Halt(deny)
670 },
671 },
672 Err(e) => EffectOutcome::Halt(Decision::Deny {
673 reason: Some(format!("PDP error: {}", e)),
674 rule_source: format!("pdp:{:?}", call.dialect),
675 }),
676 }
677 },
678 }
679}
680
681async fn dispatch_elicitation(
697 step: &crate::step::ElicitStep,
698 bag: &mut AttributeBag,
699 elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
700) -> EffectOutcome {
701 use crate::step::{elicitation_bag_keys as bk, ElicitationOutcome, ElicitationStatus};
702
703 let on_error_continue = step
706 .on_error
707 .as_deref()
708 .unwrap_or("deny")
709 .eq_ignore_ascii_case("continue");
710 let fail = |reason: String| -> EffectOutcome {
711 if on_error_continue {
712 EffectOutcome::Continue
713 } else {
714 EffectOutcome::Halt(Decision::Deny {
715 reason: Some(reason),
716 rule_source: step.source.clone(),
717 })
718 }
719 };
720
721 let resolved_from = match bag.get_string(&step.from) {
735 Some(v) => v.to_string(),
736 None if looks_like_attribute_ref(&step.from) => {
737 return fail(format!(
738 "elicitation `from` attribute `{}` did not resolve to an identity",
739 step.from
740 ));
741 },
742 None => step.from.clone(),
743 };
744 let id = match bag.get_string(bk::ID) {
745 Some(existing) => existing.to_string(),
746 None => match elicitations.dispatch(step, &resolved_from).await {
747 Ok(d) => {
748 bag.set(bk::ID, d.id.clone());
749 bag.set(bk::STATUS, "pending".to_string());
750 if let Some(channel) = &step.channel {
752 bag.set(bk::CHANNEL, channel.clone());
753 }
754 if let Some(approver) = &d.approver {
755 bag.set(bk::APPROVER, approver.clone());
756 }
757 if let Some(intent) = &d.intent_id {
758 bag.set(bk::INTENT_ID, intent.clone());
759 }
760 if let Some(exp) = &d.expires_at {
761 bag.set(bk::EXPIRES_AT, exp.clone());
762 }
763 d.id
764 },
765 Err(e) => return fail(format!("elicitation dispatch failed: {e}")),
766 },
767 };
768
769 let status = match elicitations.check(step, &id).await {
771 Ok(s) => s,
772 Err(e) => return fail(format!("elicitation check failed: {e}")),
773 };
774
775 match status {
776 ElicitationStatus::Pending => {
777 bag.set(bk::STATUS, "pending".to_string());
782 let approver = bag.get_string(bk::APPROVER).map(str::to_string);
783 let intent_id = bag.get_string(bk::INTENT_ID).map(str::to_string);
784 let expires_at = bag.get_string(bk::EXPIRES_AT).map(str::to_string);
785 EffectOutcome::Pending(crate::step::PendingElicitation {
786 id,
787 plugin_name: step.plugin_name.clone(),
788 approver,
789 intent_id,
790 channel: step.channel.clone(),
791 expires_at,
792 source: step.source.clone(),
793 })
794 },
795 ElicitationStatus::Expired => {
796 bag.set(bk::STATUS, "expired".to_string());
797 fail("elicitation expired before a response".to_string())
798 },
799 ElicitationStatus::Resolved {
800 outcome: ElicitationOutcome::Denied,
801 } => {
802 bag.set(bk::STATUS, "resolved".to_string());
803 bag.set(bk::OUTCOME, "denied".to_string());
804 EffectOutcome::Halt(Decision::Deny {
807 reason: Some("elicitation denied by approver".to_string()),
808 rule_source: step.source.clone(),
809 })
810 },
811 ElicitationStatus::Resolved {
812 outcome: ElicitationOutcome::Approved,
813 } => {
814 let validation = match elicitations.validate(step, &id).await {
817 Ok(v) => v,
818 Err(e) => return fail(format!("elicitation validation failed: {e}")),
819 };
820 if !validation.valid {
821 let why = validation
822 .reason
823 .unwrap_or_else(|| "response failed validation".to_string());
824 return fail(format!("elicitation invalid: {why}"));
825 }
826
827 if let Some(scope_src) = &step.scope {
831 match crate::parser::parse_predicate(scope_src) {
832 Ok(expr) => {
833 if !eval_expression(&expr, bag) {
834 return fail(format!("elicitation scope not satisfied: `{scope_src}`"));
835 }
836 },
837 Err(e) => {
838 return fail(format!(
839 "elicitation scope `{scope_src}` failed to parse: {e}"
840 ));
841 },
842 }
843 }
844
845 bag.set(bk::STATUS, "resolved".to_string());
848 bag.set(bk::OUTCOME, "approved".to_string());
849 if let Some(approver) = validation.approver {
850 bag.set(bk::APPROVER, approver);
851 }
852 if let Some(intent) = validation.intent_id {
853 bag.set(bk::INTENT_ID, intent);
854 }
855 EffectOutcome::Continue
856 },
857 }
858}
859
860fn dispatch_parallel<'a>(
898 effects: &'a [Effect],
899 fallback_source: &'a str,
900 bag: &'a AttributeBag,
901 pdp: &'a Arc<dyn PdpResolver>,
902 plugins: &'a Arc<dyn PluginInvoker>,
903 delegations: &'a Arc<dyn crate::step::DelegationInvoker>,
904 elicitations: &'a Arc<dyn crate::step::ElicitationInvoker>,
905 phase: crate::step::DispatchPhase,
906 taints: &'a mut Vec<crate::pipeline::TaintEvent>,
907 payload: &'a crate::route::RoutePayload,
908) -> futures::future::BoxFuture<'a, EffectOutcome> {
909 Box::pin(async move {
910 use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
911
912 if effects.is_empty() {
913 return EffectOutcome::Continue;
914 }
915
916 let mut branches: Vec<ErasedBranch<(EffectOutcome, Vec<crate::pipeline::TaintEvent>)>> =
925 Vec::with_capacity(effects.len());
926 for effect in effects.iter() {
927 let effect = effect.clone();
928 let fallback = fallback_source.to_string();
929 let mut branch_bag = bag.clone();
930 let mut branch_payload = payload.clone();
931 let pdp = Arc::clone(pdp);
932 let plugins = Arc::clone(plugins);
933 let delegations = Arc::clone(delegations);
934 let elicitations = Arc::clone(elicitations);
935 branches.push(Box::pin(async move {
936 let mut branch_taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
937 let mut branch_args_modified = false;
938 let mut branch_result_modified = false;
939 let outcome = Box::pin(dispatch_effect(
940 &effect,
941 &fallback,
942 &mut branch_bag,
943 &pdp,
944 &plugins,
945 &delegations,
946 &elicitations,
947 phase,
948 &mut branch_taints,
949 &mut branch_args_modified,
950 &mut branch_result_modified,
951 &mut branch_payload,
952 ))
953 .await;
954 (outcome, branch_taints)
955 }));
956 }
957
958 let cfg = BranchConfig {
963 timeout_per_branch: None,
964 short_circuit_on_deny: true,
965 };
966 let outcomes = run_branches(
967 branches,
968 cfg,
969 |v: &(EffectOutcome, Vec<crate::pipeline::TaintEvent>)| {
970 matches!(v.0, EffectOutcome::Halt(_))
971 },
972 )
973 .await;
974
975 let mut first_halt: Option<Decision> = None;
983 for (idx, outcome) in outcomes.into_iter().enumerate() {
984 match outcome {
985 BranchOutcome::Completed((effect_outcome, branch_taints)) => {
986 taints.extend(branch_taints);
987 if first_halt.is_none() {
988 if let EffectOutcome::Halt(d) = effect_outcome {
989 first_halt = Some(d);
990 }
991 }
992 },
993 BranchOutcome::Aborted => {
994 },
997 BranchOutcome::TimedOut => {
998 },
1002 BranchOutcome::Panicked(msg) => {
1003 let _ = (idx, msg);
1011 },
1012 }
1013 }
1014
1015 match first_halt {
1016 Some(d) => EffectOutcome::Halt(d),
1017 None => EffectOutcome::Continue,
1018 }
1019 })
1020}
1021
1022#[allow(clippy::too_many_arguments)]
1037async fn dispatch_field_op(
1038 path: &str,
1039 stages: &[crate::pipeline::Stage],
1040 fallback_source: &str,
1041 bag: &mut AttributeBag,
1042 plugins: &Arc<dyn PluginInvoker>,
1043 phase: crate::step::DispatchPhase,
1044 taints: &mut Vec<crate::pipeline::TaintEvent>,
1045 args_modified: &mut bool,
1046 result_modified: &mut bool,
1047 payload: &mut crate::route::RoutePayload,
1048) -> EffectOutcome {
1049 use crate::route::{get_dotted, remove_dotted, set_dotted};
1050 use crate::step::DispatchPhase;
1051
1052 enum Side {
1055 Args,
1056 Result,
1057 }
1058 let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") {
1059 if !matches!(phase, DispatchPhase::Pre) {
1060 return EffectOutcome::Continue;
1061 }
1062 (&mut payload.args, rest, Side::Args)
1063 } else if let Some(rest) = path.strip_prefix("result.") {
1064 if !matches!(phase, DispatchPhase::Post) {
1065 return EffectOutcome::Continue;
1066 }
1067 let Some(result) = payload.result.as_mut() else {
1068 return EffectOutcome::Continue;
1069 };
1070 (result, rest, Side::Result)
1071 } else {
1072 return EffectOutcome::Halt(Decision::Deny {
1073 reason: Some(format!(
1074 "FieldOp path `{}` must start with `args.` or `result.`",
1075 path
1076 )),
1077 rule_source: fallback_source.to_string(),
1078 });
1079 };
1080
1081 let Some(current) = get_dotted(root, subpath).cloned() else {
1082 return EffectOutcome::Continue; };
1084
1085 let pipeline = crate::pipeline::Pipeline {
1086 stages: stages.to_vec(),
1087 };
1088 let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, phase).await;
1089 taints.extend(eval.taints);
1090 let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side {
1091 Side::Args => *args = true,
1092 Side::Result => *result = true,
1093 };
1094 match eval.outcome {
1095 FieldOutcome::Pass => EffectOutcome::Continue,
1096 FieldOutcome::Replace(new_val) => {
1097 if set_dotted(root, subpath, new_val) {
1098 mark_modified(side, args_modified, result_modified);
1099 }
1100 EffectOutcome::Continue
1101 },
1102 FieldOutcome::Omit => {
1103 if remove_dotted(root, subpath) {
1104 mark_modified(side, args_modified, result_modified);
1105 }
1106 EffectOutcome::Continue
1107 },
1108 FieldOutcome::Deny {
1109 reason,
1110 stage_index: _,
1111 } => EffectOutcome::Halt(Decision::Deny {
1112 reason: Some(reason),
1113 rule_source: fallback_source.to_string(),
1114 }),
1115 }
1116}
1117
1118#[derive(Debug, Clone, PartialEq)]
1130pub enum FieldOutcome {
1131 Pass,
1132 Replace(serde_json::Value),
1133 Omit,
1134 Deny { reason: String, stage_index: usize },
1135}
1136
1137#[derive(Debug, Clone, PartialEq)]
1146pub struct PipelineEvaluation {
1147 pub outcome: FieldOutcome,
1148 pub taints: Vec<TaintEvent>,
1149}
1150
1151pub async fn evaluate_pipeline(
1164 pipeline: &Pipeline,
1165 value: &serde_json::Value,
1166 bag: &AttributeBag,
1167 plugins: &Arc<dyn PluginInvoker>,
1168 field_name: &str,
1169 phase: crate::step::DispatchPhase,
1170) -> PipelineEvaluation {
1171 let mut current = value.clone();
1172 let mut replaced = false;
1173 let mut taints: Vec<TaintEvent> = Vec::new();
1174
1175 for (idx, stage) in pipeline.stages.iter().enumerate() {
1176 match stage {
1177 Stage::Type(tc) => {
1179 if !type_check(tc, ¤t) {
1180 return PipelineEvaluation {
1181 outcome: FieldOutcome::Deny {
1182 reason: format!("expected {:?}, got {}", tc, value_kind(¤t)),
1183 stage_index: idx,
1184 },
1185 taints,
1186 };
1187 }
1188 },
1189 Stage::Length { min, max } => {
1190 let Some(s) = current.as_str() else {
1191 return PipelineEvaluation {
1192 outcome: FieldOutcome::Deny {
1193 reason: format!(
1194 "len(...) requires string value, got {}",
1195 value_kind(¤t)
1196 ),
1197 stage_index: idx,
1198 },
1199 taints,
1200 };
1201 };
1202 let len = s.chars().count();
1203 if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) {
1204 return PipelineEvaluation {
1205 outcome: FieldOutcome::Deny {
1206 reason: format!("length {} outside [{:?}, {:?}]", len, min, max),
1207 stage_index: idx,
1208 },
1209 taints,
1210 };
1211 }
1212 },
1213 Stage::Range { min, max } => {
1214 let Some(n) = current.as_i64() else {
1215 return PipelineEvaluation {
1216 outcome: FieldOutcome::Deny {
1217 reason: format!(
1218 "range requires integer value, got {}",
1219 value_kind(¤t)
1220 ),
1221 stage_index: idx,
1222 },
1223 taints,
1224 };
1225 };
1226 if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) {
1227 return PipelineEvaluation {
1228 outcome: FieldOutcome::Deny {
1229 reason: format!("value {} outside [{:?}, {:?}]", n, min, max),
1230 stage_index: idx,
1231 },
1232 taints,
1233 };
1234 }
1235 },
1236 Stage::Enum { values } => {
1237 let Some(s) = current.as_str() else {
1238 return PipelineEvaluation {
1239 outcome: FieldOutcome::Deny {
1240 reason: format!(
1241 "enum(...) requires string value, got {}",
1242 value_kind(¤t)
1243 ),
1244 stage_index: idx,
1245 },
1246 taints,
1247 };
1248 };
1249 if !values.iter().any(|v| v == s) {
1250 return PipelineEvaluation {
1251 outcome: FieldOutcome::Deny {
1252 reason: format!("value `{}` not in enum {:?}", s, values),
1253 stage_index: idx,
1254 },
1255 taints,
1256 };
1257 }
1258 },
1259 Stage::Regex { pattern } => {
1260 let re = match regex::Regex::new(pattern) {
1263 Ok(r) => r,
1264 Err(e) => {
1265 return PipelineEvaluation {
1266 outcome: FieldOutcome::Deny {
1267 reason: format!("invalid regex `{}`: {}", pattern, e),
1268 stage_index: idx,
1269 },
1270 taints,
1271 };
1272 },
1273 };
1274 let Some(s) = current.as_str() else {
1275 return PipelineEvaluation {
1276 outcome: FieldOutcome::Deny {
1277 reason: format!(
1278 "regex requires string value, got {}",
1279 value_kind(¤t)
1280 ),
1281 stage_index: idx,
1282 },
1283 taints,
1284 };
1285 };
1286 if !re.is_match(s) {
1287 return PipelineEvaluation {
1288 outcome: FieldOutcome::Deny {
1289 reason: format!("value did not match regex `{}`", pattern),
1290 stage_index: idx,
1291 },
1292 taints,
1293 };
1294 }
1295 },
1296 Stage::Validate { name } => {
1297 return PipelineEvaluation {
1304 outcome: FieldOutcome::Deny {
1305 reason: format!(
1306 "`validate({})` is not implemented; use `regex(...)` \
1307 or `plugin({})` instead",
1308 name, name,
1309 ),
1310 stage_index: idx,
1311 },
1312 taints,
1313 };
1314 },
1315
1316 Stage::Mask { keep_last } => {
1318 let Some(s) = current.as_str() else {
1319 return PipelineEvaluation {
1320 outcome: FieldOutcome::Deny {
1321 reason: format!(
1322 "mask(...) requires string value, got {}",
1323 value_kind(¤t)
1324 ),
1325 stage_index: idx,
1326 },
1327 taints,
1328 };
1329 };
1330 let chars: Vec<char> = s.chars().collect();
1331 let keep = (*keep_last).min(chars.len());
1332 let mask_count = chars.len() - keep;
1333 let masked: String = std::iter::repeat('*')
1334 .take(mask_count)
1335 .chain(chars.into_iter().skip(mask_count))
1336 .collect();
1337 current = serde_json::Value::String(masked);
1338 replaced = true;
1339 },
1340 Stage::Redact { condition } => {
1341 let should_redact = match condition {
1342 None => true,
1343 Some(expr) => eval_expression(expr, bag),
1344 };
1345 if should_redact {
1346 current = serde_json::Value::String("[REDACTED]".into());
1347 replaced = true;
1348 }
1349 },
1350 Stage::Omit => {
1351 return PipelineEvaluation {
1352 outcome: FieldOutcome::Omit,
1353 taints,
1354 };
1355 },
1356 Stage::Hash => {
1357 use std::hash::{Hash, Hasher};
1360 let mut h = std::collections::hash_map::DefaultHasher::new();
1361 value_for_hash(¤t).hash(&mut h);
1362 current = serde_json::Value::String(format!("hash:{:016x}", h.finish()));
1363 replaced = true;
1364 },
1365
1366 Stage::Taint { label, scopes } => {
1368 taints.push(TaintEvent {
1369 label: label.clone(),
1370 scopes: scopes.clone(),
1371 });
1372 },
1373 Stage::Plugin { name } => {
1374 let invocation = PluginInvocation::Field {
1375 name: field_name,
1376 value: ¤t,
1377 phase,
1378 };
1379 match plugins.invoke(name, bag, invocation).await {
1380 Ok(outcome) => {
1381 taints.extend(outcome.taints);
1383 match outcome.decision {
1384 Decision::Allow => {
1385 if let Some(new_value) = outcome.modified_value {
1386 current = new_value;
1387 replaced = true;
1388 }
1389 },
1390 Decision::Deny {
1391 reason,
1392 rule_source: _,
1393 } => {
1394 return PipelineEvaluation {
1395 outcome: FieldOutcome::Deny {
1396 reason: reason
1397 .unwrap_or_else(|| format!("plugin `{}` denied", name)),
1398 stage_index: idx,
1399 },
1400 taints,
1401 };
1402 },
1403 }
1404 },
1405 Err(e) => {
1406 return PipelineEvaluation {
1408 outcome: FieldOutcome::Deny {
1409 reason: format!("plugin `{}` error: {}", name, e),
1410 stage_index: idx,
1411 },
1412 taints,
1413 };
1414 },
1415 }
1416 },
1417 Stage::Scan { kind } => {
1418 let (label, redact): (&str, bool) = match kind {
1424 ScanKind::PiiDetect => ("PII", false),
1425 ScanKind::PiiRedact => ("PII", true),
1426 ScanKind::InjectionScan => ("injection", false),
1427 };
1428 taints.push(TaintEvent {
1429 label: label.to_string(),
1430 scopes: vec![TaintScope::Session],
1431 });
1432 if redact {
1433 current = serde_json::Value::String("[REDACTED]".into());
1434 replaced = true;
1435 }
1436 },
1437 }
1438 }
1439
1440 let outcome = if replaced {
1441 FieldOutcome::Replace(current)
1442 } else {
1443 FieldOutcome::Pass
1444 };
1445 PipelineEvaluation { outcome, taints }
1446}
1447
1448fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool {
1449 match tc {
1450 TypeCheck::Str => v.is_string(),
1451 TypeCheck::Int => v.is_i64(),
1452 TypeCheck::Bool => v.is_boolean(),
1453 TypeCheck::Float => v.is_f64() || v.is_i64(),
1454 TypeCheck::Email => v
1455 .as_str()
1456 .map_or(false, |s| s.contains('@') && s.contains('.')),
1457 TypeCheck::Url => v.as_str().map_or(false, |s| {
1458 s.starts_with("http://") || s.starts_with("https://")
1459 }),
1460 TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape),
1461 }
1462}
1463
1464fn is_uuid_shape(s: &str) -> bool {
1465 let bytes = s.as_bytes();
1467 if bytes.len() != 36 {
1468 return false;
1469 }
1470 for (i, &b) in bytes.iter().enumerate() {
1471 match i {
1472 8 | 13 | 18 | 23 => {
1473 if b != b'-' {
1474 return false;
1475 }
1476 },
1477 _ => {
1478 if !b.is_ascii_hexdigit() {
1479 return false;
1480 }
1481 },
1482 }
1483 }
1484 true
1485}
1486
1487fn value_kind(v: &serde_json::Value) -> &'static str {
1488 match v {
1489 serde_json::Value::Null => "null",
1490 serde_json::Value::Bool(_) => "bool",
1491 serde_json::Value::Number(n) if n.is_i64() => "int",
1492 serde_json::Value::Number(_) => "float",
1493 serde_json::Value::String(_) => "string",
1494 serde_json::Value::Array(_) => "array",
1495 serde_json::Value::Object(_) => "object",
1496 }
1497}
1498
1499fn value_for_hash(v: &serde_json::Value) -> String {
1502 serde_json::to_string(v).unwrap_or_default()
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508 use crate::rules::{Condition, Expression, Rule};
1509 use crate::step::{DelegationInvoker, NoopDelegationInvoker};
1510 use std::collections::HashSet;
1511 use std::sync::Arc;
1512
1513 fn rule(condition: Expression, effect: Effect, source: &str) -> Rule {
1514 Rule::single(condition, effect, source)
1515 }
1516
1517 fn null_pipe_plugins() -> Arc<dyn PluginInvoker> {
1522 Arc::new(NullPipelinePlugins)
1523 }
1524 fn null_plugins() -> Arc<dyn PluginInvoker> {
1525 Arc::new(NullPlugins)
1526 }
1527 fn noop_delegations() -> Arc<dyn DelegationInvoker> {
1528 Arc::new(NoopDelegationInvoker)
1529 }
1530 fn noop_elicitations() -> Arc<dyn crate::step::ElicitationInvoker> {
1531 Arc::new(crate::step::NoopElicitationInvoker)
1532 }
1533 fn auto_elicitations() -> Arc<dyn crate::step::ElicitationInvoker> {
1534 Arc::new(crate::step::AutoApprovingElicitor)
1535 }
1536
1537 fn deny(reason: &str) -> Effect {
1538 Effect::Deny {
1539 reason: Some(reason.into()),
1540 code: None,
1541 }
1542 }
1543
1544 fn elicit_effect(scope: Option<&str>, on_error: Option<&str>) -> Effect {
1548 Effect::Elicit(crate::step::ElicitStep {
1549 kind: crate::step::ElicitKind::Approval,
1550 plugin_name: "manager-approver".into(),
1551 channel: Some("ciba".into()),
1552 from: "user.manager".into(),
1553 purpose: Some("approve payroll adjustment".into()),
1554 scope: scope.map(|s| s.to_string()),
1555 timeout: None,
1556 config_override: None,
1557 on_error: on_error.map(|s| s.to_string()),
1558 source: "route.test.policy[0]".into(),
1559 })
1560 }
1561
1562 fn cond(c: Condition) -> Expression {
1563 Expression::Condition(c)
1564 }
1565
1566 #[test]
1569 fn empty_rules_allow() {
1570 let mut bag = AttributeBag::new();
1571 assert_eq!(evaluate_rules(&[], &bag), Decision::Allow);
1572 }
1573
1574 #[test]
1575 fn first_deny_halts() {
1576 let mut bag = AttributeBag::new();
1577 bag.set("a", true);
1578 bag.set("b", true);
1579
1580 let rules = vec![
1581 rule(
1582 cond(Condition::IsTrue { key: "a".into() }),
1583 deny("first"),
1584 "r0",
1585 ),
1586 rule(
1587 cond(Condition::IsTrue { key: "b".into() }),
1588 deny("second"),
1589 "r1",
1590 ),
1591 ];
1592
1593 match evaluate_rules(&rules, &bag) {
1594 Decision::Deny {
1595 reason,
1596 rule_source,
1597 } => {
1598 assert_eq!(reason.as_deref(), Some("first"));
1599 assert_eq!(rule_source, "r0");
1600 },
1601 d => panic!("expected Deny, got {:?}", d),
1602 }
1603 }
1604
1605 #[test]
1606 fn allow_does_not_short_circuit() {
1607 let mut bag = AttributeBag::new();
1609 bag.set("ok", true);
1610 bag.set("bad", true);
1611
1612 let rules = vec![
1613 rule(
1614 cond(Condition::IsTrue { key: "ok".into() }),
1615 Effect::Allow,
1616 "r0_allow",
1617 ),
1618 rule(
1619 cond(Condition::IsTrue { key: "bad".into() }),
1620 deny("later"),
1621 "r1_deny",
1622 ),
1623 ];
1624
1625 match evaluate_rules(&rules, &bag) {
1626 Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"),
1627 d => panic!("allow short-circuited; expected later deny, got {:?}", d),
1628 }
1629 }
1630
1631 #[test]
1632 fn unmatched_rules_dont_fire() {
1633 let mut bag = AttributeBag::new(); let rules = vec![rule(
1635 cond(Condition::IsTrue {
1636 key: "denied".into(),
1637 }),
1638 deny("shouldn't fire"),
1639 "r0",
1640 )];
1641 assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1642 }
1643
1644 #[test]
1647 fn missing_key_is_false() {
1648 let mut bag = AttributeBag::new();
1649 assert!(!eval_condition(
1650 &Condition::IsTrue {
1651 key: "missing".into()
1652 },
1653 &bag
1654 ));
1655 assert!(eval_condition(
1656 &Condition::IsFalse {
1657 key: "missing".into()
1658 },
1659 &bag
1660 ));
1661 assert!(!eval_condition(
1663 &Condition::Comparison {
1664 key: "missing".into(),
1665 op: CompareOp::Eq,
1666 value: 1_i64.into(),
1667 },
1668 &bag,
1669 ));
1670 }
1671
1672 #[test]
1673 fn and_or_not_combinators() {
1674 let mut bag = AttributeBag::new();
1675 bag.set("a", true);
1676 bag.set("b", false);
1677
1678 let a = cond(Condition::IsTrue { key: "a".into() });
1679 let b = cond(Condition::IsTrue { key: "b".into() });
1680
1681 assert!(eval_expression(
1682 &Expression::And(vec![a.clone(), a.clone()]),
1683 &bag
1684 ));
1685 assert!(!eval_expression(
1686 &Expression::And(vec![a.clone(), b.clone()]),
1687 &bag
1688 ));
1689 assert!(eval_expression(
1690 &Expression::Or(vec![a.clone(), b.clone()]),
1691 &bag
1692 ));
1693 assert!(!eval_expression(
1694 &Expression::Or(vec![b.clone(), b.clone()]),
1695 &bag
1696 ));
1697 assert!(eval_expression(&Expression::Not(Box::new(b)), &bag));
1698 }
1699
1700 #[test]
1703 fn int_comparisons() {
1704 let mut bag = AttributeBag::new();
1705 bag.set("delegation.depth", 3_i64);
1706
1707 let cmp = |op| Condition::Comparison {
1708 key: "delegation.depth".into(),
1709 op,
1710 value: 2_i64.into(),
1711 };
1712 assert!(eval_condition(&cmp(CompareOp::Gt), &bag));
1713 assert!(eval_condition(&cmp(CompareOp::GtEq), &bag));
1714 assert!(!eval_condition(&cmp(CompareOp::Lt), &bag));
1715 assert!(!eval_condition(&cmp(CompareOp::Eq), &bag));
1716 assert!(eval_condition(&cmp(CompareOp::NotEq), &bag));
1717 }
1718
1719 #[test]
1720 fn int_to_float_promotion_in_comparison() {
1721 let mut bag = AttributeBag::new();
1722 bag.set("delegation.depth", 2_i64);
1723 assert!(!eval_condition(
1725 &Condition::Comparison {
1726 key: "delegation.depth".into(),
1727 op: CompareOp::Gt,
1728 value: 2.5_f64.into(),
1729 },
1730 &bag,
1731 ));
1732 assert!(eval_condition(
1733 &Condition::Comparison {
1734 key: "delegation.depth".into(),
1735 op: CompareOp::Lt,
1736 value: 2.5_f64.into(),
1737 },
1738 &bag,
1739 ));
1740 }
1741
1742 #[test]
1743 fn numeric_string_args_coerce_for_order_comparison() {
1744 let mut bag = AttributeBag::new();
1750 bag.set("args.amount", "25000");
1751 let cmp = |op, v: i64| Condition::Comparison {
1752 key: "args.amount".into(),
1753 op,
1754 value: v.into(),
1755 };
1756 assert!(
1757 eval_condition(&cmp(CompareOp::Gt, 10000), &bag),
1758 "\"25000\" > 10000"
1759 );
1760 assert!(
1761 eval_condition(&cmp(CompareOp::LtEq, 25000), &bag),
1762 "\"25000\" <= 25000"
1763 );
1764 assert!(
1765 !eval_condition(&cmp(CompareOp::Gt, 25000), &bag),
1766 "\"25000\" > 25000 is false"
1767 );
1768
1769 bag.set("args.amount", "lots");
1771 assert!(
1772 !eval_condition(&cmp(CompareOp::Gt, 10000), &bag),
1773 "\"lots\" > 10000 is false"
1774 );
1775 }
1776
1777 #[test]
1778 fn string_equality_no_ordering() {
1779 let mut bag = AttributeBag::new();
1780 bag.set("subject.id", "alice");
1781
1782 assert!(eval_condition(
1783 &Condition::Comparison {
1784 key: "subject.id".into(),
1785 op: CompareOp::Eq,
1786 value: "alice".into(),
1787 },
1788 &bag,
1789 ));
1790 assert!(!eval_condition(
1792 &Condition::Comparison {
1793 key: "subject.id".into(),
1794 op: CompareOp::Gt,
1795 value: "alice".into(),
1796 },
1797 &bag,
1798 ));
1799 }
1800
1801 #[test]
1802 fn contains_set_membership() {
1803 let mut bag = AttributeBag::new();
1804 bag.set(
1805 "session.labels",
1806 HashSet::from(["PII".to_string(), "financial".to_string()]),
1807 );
1808
1809 assert!(eval_condition(
1810 &Condition::Comparison {
1811 key: "session.labels".into(),
1812 op: CompareOp::Contains,
1813 value: "PII".into(),
1814 },
1815 &bag,
1816 ));
1817 assert!(!eval_condition(
1818 &Condition::Comparison {
1819 key: "session.labels".into(),
1820 op: CompareOp::Contains,
1821 value: "PHI".into(),
1822 },
1823 &bag,
1824 ));
1825 bag.set("subject.id", "alice");
1827 assert!(!eval_condition(
1828 &Condition::Comparison {
1829 key: "subject.id".into(),
1830 op: CompareOp::Contains,
1831 value: "alice".into(),
1832 },
1833 &bag,
1834 ));
1835 }
1836
1837 #[test]
1840 fn hr_compensation_scenario() {
1841 let mut bag = AttributeBag::new();
1848 bag.set("authenticated", true);
1849 bag.set("role.hr", true);
1850 bag.set("perm.view_ssn", true);
1851 bag.set("delegation.depth", 1_i64);
1852 bag.set("include_ssn", true);
1853
1854 let rules = vec![
1855 rule(
1857 Expression::Not(Box::new(cond(Condition::IsTrue {
1858 key: "authenticated".into(),
1859 }))),
1860 deny("not authenticated"),
1861 "r0",
1862 ),
1863 rule(
1867 Expression::And(vec![
1868 cond(Condition::IsFalse {
1869 key: "role.hr".into(),
1870 }),
1871 cond(Condition::IsFalse {
1872 key: "role.finance".into(),
1873 }),
1874 ]),
1875 deny("not in hr/finance"),
1876 "r1",
1877 ),
1878 rule(
1880 Expression::And(vec![
1881 cond(Condition::Comparison {
1882 key: "delegation.depth".into(),
1883 op: CompareOp::Gt,
1884 value: 2_i64.into(),
1885 }),
1886 cond(Condition::IsTrue {
1887 key: "include_ssn".into(),
1888 }),
1889 ]),
1890 deny("delegation too deep for SSN"),
1891 "r2",
1892 ),
1893 ];
1894
1895 assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
1896
1897 bag.set("delegation.depth", 3_i64);
1900 match evaluate_rules(&rules, &bag) {
1901 Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"),
1902 d => panic!("expected r2 deny, got {:?}", d),
1903 }
1904 }
1905
1906 use crate::pipeline::{Stage, TypeCheck};
1911 use serde_json::json;
1912
1913 fn make_pipeline(stages: Vec<Stage>) -> crate::pipeline::Pipeline {
1914 crate::pipeline::Pipeline { stages }
1915 }
1916
1917 async fn run_pipeline(
1922 p: &crate::pipeline::Pipeline,
1923 v: &serde_json::Value,
1924 bag: &AttributeBag,
1925 ) -> FieldOutcome {
1926 evaluate_pipeline(
1927 p,
1928 v,
1929 bag,
1930 &null_pipe_plugins(),
1931 "test_field",
1932 crate::step::DispatchPhase::Pre,
1933 )
1934 .await
1935 .outcome
1936 }
1937
1938 struct NullPipelinePlugins;
1942 #[async_trait]
1943 impl PluginInvoker for NullPipelinePlugins {
1944 async fn invoke(
1945 &self,
1946 name: &str,
1947 _bag: &AttributeBag,
1948 _invocation: PluginInvocation<'_>,
1949 ) -> Result<PluginOutcome, PluginError> {
1950 panic!(
1951 "NullPipelinePlugins should not dispatch; got plugin({})",
1952 name
1953 );
1954 }
1955 }
1956
1957 #[tokio::test]
1958 async fn pipeline_empty_is_pass() {
1959 let mut bag = AttributeBag::new();
1960 let p = make_pipeline(vec![]);
1961 assert_eq!(
1962 run_pipeline(&p, &json!("anything"), &bag).await,
1963 FieldOutcome::Pass
1964 );
1965 }
1966
1967 #[tokio::test]
1968 async fn pipeline_type_check_passes_and_denies() {
1969 let mut bag = AttributeBag::new();
1970 let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]);
1971 assert_eq!(
1972 run_pipeline(&p, &json!("hello"), &bag).await,
1973 FieldOutcome::Pass
1974 );
1975 match run_pipeline(&p, &json!(42), &bag).await {
1976 FieldOutcome::Deny {
1977 reason,
1978 stage_index,
1979 } => {
1980 assert!(reason.contains("expected Str"));
1981 assert_eq!(stage_index, 0);
1982 },
1983 other => panic!("expected Deny, got {:?}", other),
1984 }
1985 }
1986
1987 #[tokio::test]
1988 async fn pipeline_mask_preserves_last_n() {
1989 let mut bag = AttributeBag::new();
1990 let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
1991 match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
1992 FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")),
1993 other => panic!("expected Replace, got {:?}", other),
1994 }
1995 }
1996
1997 #[tokio::test]
1998 async fn pipeline_mask_handles_short_strings() {
1999 let mut bag = AttributeBag::new();
2000 let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
2001 match run_pipeline(&p, &json!("ab"), &bag).await {
2003 FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")),
2004 other => panic!("expected Replace, got {:?}", other),
2005 }
2006 }
2007
2008 #[tokio::test]
2009 async fn pipeline_unconditional_redact() {
2010 let mut bag = AttributeBag::new();
2011 let p = make_pipeline(vec![Stage::Redact { condition: None }]);
2012 match run_pipeline(&p, &json!("secret"), &bag).await {
2013 FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
2014 other => panic!("expected Replace, got {:?}", other),
2015 }
2016 }
2017
2018 #[tokio::test]
2019 async fn pipeline_conditional_redact_fires_when_condition_true() {
2020 let mut bag = AttributeBag::new();
2023 let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
2024 key: "perm.view_ssn".into(),
2025 })));
2026 let p = make_pipeline(vec![Stage::Redact {
2027 condition: Some(cond),
2028 }]);
2029 match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
2030 FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
2031 other => panic!("expected Replace (redact fired), got {:?}", other),
2032 }
2033 }
2034
2035 #[tokio::test]
2036 async fn pipeline_conditional_redact_skips_when_condition_false() {
2037 let mut bag = AttributeBag::new();
2038 bag.set("perm.view_ssn", true);
2039 let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
2040 key: "perm.view_ssn".into(),
2041 })));
2042 let p = make_pipeline(vec![Stage::Redact {
2043 condition: Some(cond),
2044 }]);
2045 assert_eq!(
2047 run_pipeline(&p, &json!("123-45-6789"), &bag).await,
2048 FieldOutcome::Pass,
2049 );
2050 }
2051
2052 #[tokio::test]
2053 async fn pipeline_omit_short_circuits() {
2054 let mut bag = AttributeBag::new();
2055 let p = make_pipeline(vec![
2056 Stage::Omit,
2057 Stage::Type(TypeCheck::Int),
2059 ]);
2060 assert_eq!(
2061 run_pipeline(&p, &json!("anything"), &bag).await,
2062 FieldOutcome::Omit
2063 );
2064 }
2065
2066 #[tokio::test]
2067 async fn pipeline_range_validator() {
2068 let mut bag = AttributeBag::new();
2069 let p = make_pipeline(vec![
2070 Stage::Type(TypeCheck::Int),
2071 Stage::Range {
2072 min: Some(0),
2073 max: Some(1_000_000),
2074 },
2075 ]);
2076 assert_eq!(
2077 run_pipeline(&p, &json!(500_000), &bag).await,
2078 FieldOutcome::Pass
2079 );
2080 match run_pipeline(&p, &json!(2_000_000), &bag).await {
2082 FieldOutcome::Deny {
2083 reason,
2084 stage_index,
2085 } => {
2086 assert!(reason.contains("outside"));
2087 assert_eq!(stage_index, 1);
2088 },
2089 other => panic!("expected Deny, got {:?}", other),
2090 }
2091 }
2092
2093 #[tokio::test]
2094 async fn pipeline_length_validator() {
2095 let mut bag = AttributeBag::new();
2096 let p = make_pipeline(vec![Stage::Length {
2097 min: None,
2098 max: Some(5),
2099 }]);
2100 assert_eq!(
2101 run_pipeline(&p, &json!("hi"), &bag).await,
2102 FieldOutcome::Pass
2103 );
2104 assert!(matches!(
2105 run_pipeline(&p, &json!("too long"), &bag).await,
2106 FieldOutcome::Deny { .. },
2107 ));
2108 }
2109
2110 #[tokio::test]
2111 async fn pipeline_enum_validator() {
2112 let mut bag = AttributeBag::new();
2113 let p = make_pipeline(vec![Stage::Enum {
2114 values: vec!["low".into(), "medium".into(), "high".into()],
2115 }]);
2116 assert_eq!(
2117 run_pipeline(&p, &json!("medium"), &bag).await,
2118 FieldOutcome::Pass
2119 );
2120 assert!(matches!(
2121 run_pipeline(&p, &json!("extreme"), &bag).await,
2122 FieldOutcome::Deny { .. },
2123 ));
2124 }
2125
2126 #[tokio::test]
2127 async fn pipeline_uuid_validator() {
2128 let mut bag = AttributeBag::new();
2129 let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]);
2130 assert_eq!(
2131 run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await,
2132 FieldOutcome::Pass,
2133 );
2134 assert!(matches!(
2135 run_pipeline(&p, &json!("not-a-uuid"), &bag).await,
2136 FieldOutcome::Deny { .. },
2137 ));
2138 }
2139
2140 #[tokio::test]
2141 async fn pipeline_hash_replaces_value() {
2142 let mut bag = AttributeBag::new();
2143 let p = make_pipeline(vec![Stage::Hash]);
2144 match run_pipeline(&p, &json!("secret"), &bag).await {
2145 FieldOutcome::Replace(v) => {
2146 let s = v.as_str().unwrap();
2147 assert!(s.starts_with("hash:"));
2148 assert_eq!(s.len(), "hash:".len() + 16);
2149 },
2150 other => panic!("expected Replace, got {:?}", other),
2151 }
2152 }
2153
2154 #[tokio::test]
2155 async fn pipeline_validate_named_denies_at_runtime() {
2156 let mut bag = AttributeBag::new();
2162 let p = make_pipeline(vec![
2163 Stage::Type(TypeCheck::Str),
2164 Stage::Validate {
2165 name: "ssn_format".into(),
2166 },
2167 Stage::Mask { keep_last: 4 },
2168 ]);
2169 match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
2170 FieldOutcome::Deny {
2171 reason,
2172 stage_index,
2173 } => {
2174 assert_eq!(stage_index, 1, "validate stage is at index 1");
2175 assert!(
2176 reason.contains("not implemented"),
2177 "deny reason should explain that validate is unimplemented: {reason}",
2178 );
2179 assert!(
2180 reason.contains("regex") || reason.contains("plugin"),
2181 "deny reason should point at alternatives: {reason}",
2182 );
2183 },
2184 other => panic!("expected Deny on validate(...) stage, got {:?}", other),
2185 }
2186 }
2187
2188 #[tokio::test]
2189 async fn pipeline_validator_short_circuits_before_transform() {
2190 let mut bag = AttributeBag::new();
2192 let p = make_pipeline(vec![
2193 Stage::Type(TypeCheck::Int), Stage::Mask { keep_last: 4 },
2195 ]);
2196 match run_pipeline(&p, &json!("hello"), &bag).await {
2197 FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0),
2198 other => panic!("expected Deny at stage 0, got {:?}", other),
2199 }
2200 }
2201
2202 #[tokio::test]
2205 async fn pipeline_regex_match_passes() {
2206 let mut bag = AttributeBag::new();
2207 let p = make_pipeline(vec![Stage::Regex {
2208 pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
2209 }]);
2210 assert_eq!(
2211 run_pipeline(&p, &json!("123-45-6789"), &bag).await,
2212 FieldOutcome::Pass
2213 );
2214 }
2215
2216 #[tokio::test]
2217 async fn pipeline_regex_no_match_denies() {
2218 let mut bag = AttributeBag::new();
2219 let p = make_pipeline(vec![Stage::Regex {
2220 pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
2221 }]);
2222 match run_pipeline(&p, &json!("not an ssn"), &bag).await {
2223 FieldOutcome::Deny {
2224 reason,
2225 stage_index,
2226 } => {
2227 assert!(reason.contains("did not match"));
2228 assert_eq!(stage_index, 0);
2229 },
2230 other => panic!("expected Deny, got {:?}", other),
2231 }
2232 }
2233
2234 #[tokio::test]
2235 async fn pipeline_regex_invalid_pattern_denies() {
2236 let mut bag = AttributeBag::new();
2237 let p = make_pipeline(vec![Stage::Regex {
2238 pattern: "(unclosed".into(),
2239 }]);
2240 match run_pipeline(&p, &json!("anything"), &bag).await {
2241 FieldOutcome::Deny { reason, .. } => {
2242 assert!(reason.contains("invalid regex"));
2243 },
2244 other => panic!("expected Deny, got {:?}", other),
2245 }
2246 }
2247
2248 #[tokio::test]
2249 async fn pipeline_regex_non_string_denies() {
2250 let mut bag = AttributeBag::new();
2251 let p = make_pipeline(vec![Stage::Regex {
2252 pattern: r"^\d+$".into(),
2253 }]);
2254 match run_pipeline(&p, &json!(42), &bag).await {
2255 FieldOutcome::Deny { reason, .. } => {
2256 assert!(reason.contains("requires string"));
2257 },
2258 other => panic!("expected Deny on non-string regex input, got {:?}", other),
2259 }
2260 }
2261
2262 #[tokio::test]
2265 async fn pipeline_taint_records_event() {
2266 let mut bag = AttributeBag::new();
2267 let p = make_pipeline(vec![
2268 Stage::Type(TypeCheck::Str),
2269 Stage::Taint {
2270 label: "PII".into(),
2271 scopes: vec![TaintScope::Session],
2272 },
2273 Stage::Mask { keep_last: 4 },
2274 ]);
2275 let result = evaluate_pipeline(
2276 &p,
2277 &json!("123-45-6789"),
2278 &bag,
2279 &null_pipe_plugins(),
2280 "test_field",
2281 crate::step::DispatchPhase::Pre,
2282 )
2283 .await;
2284 assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
2285 assert_eq!(
2286 result.taints,
2287 vec![TaintEvent {
2288 label: "PII".into(),
2289 scopes: vec![TaintScope::Session],
2290 }]
2291 );
2292 }
2293
2294 #[tokio::test]
2295 async fn pipeline_scan_pii_detect_emits_taint() {
2296 let mut bag = AttributeBag::new();
2297 let p = make_pipeline(vec![Stage::Scan {
2298 kind: ScanKind::PiiDetect,
2299 }]);
2300 let result = evaluate_pipeline(
2301 &p,
2302 &json!("some text"),
2303 &bag,
2304 &null_pipe_plugins(),
2305 "test_field",
2306 crate::step::DispatchPhase::Pre,
2307 )
2308 .await;
2309 assert_eq!(result.outcome, FieldOutcome::Pass);
2311 assert_eq!(
2312 result.taints,
2313 vec![TaintEvent {
2314 label: "PII".into(),
2315 scopes: vec![TaintScope::Session],
2316 }]
2317 );
2318 }
2319
2320 #[tokio::test]
2321 async fn pipeline_scan_pii_redact_replaces_and_taints() {
2322 let mut bag = AttributeBag::new();
2323 let p = make_pipeline(vec![Stage::Scan {
2324 kind: ScanKind::PiiRedact,
2325 }]);
2326 let result = evaluate_pipeline(
2327 &p,
2328 &json!("123-45-6789"),
2329 &bag,
2330 &null_pipe_plugins(),
2331 "test_field",
2332 crate::step::DispatchPhase::Pre,
2333 )
2334 .await;
2335 assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]")));
2336 assert_eq!(result.taints.len(), 1);
2337 assert_eq!(result.taints[0].label, "PII");
2338 }
2339
2340 #[tokio::test]
2341 async fn pipeline_scan_injection_emits_injection_taint() {
2342 let mut bag = AttributeBag::new();
2343 let p = make_pipeline(vec![Stage::Scan {
2344 kind: ScanKind::InjectionScan,
2345 }]);
2346 let result = evaluate_pipeline(
2347 &p,
2348 &json!("user input"),
2349 &bag,
2350 &null_pipe_plugins(),
2351 "test_field",
2352 crate::step::DispatchPhase::Pre,
2353 )
2354 .await;
2355 assert_eq!(result.outcome, FieldOutcome::Pass);
2356 assert_eq!(result.taints[0].label, "injection");
2357 }
2358
2359 #[tokio::test]
2360 async fn pipeline_deny_does_not_accumulate_later_taints() {
2361 let mut bag = AttributeBag::new();
2364 let p = make_pipeline(vec![
2365 Stage::Taint {
2366 label: "before".into(),
2367 scopes: vec![TaintScope::Session],
2368 },
2369 Stage::Type(TypeCheck::Int), Stage::Taint {
2371 label: "after".into(),
2372 scopes: vec![TaintScope::Session],
2373 },
2374 ]);
2375 let result = evaluate_pipeline(
2376 &p,
2377 &json!("hello"),
2378 &bag,
2379 &null_pipe_plugins(),
2380 "test_field",
2381 crate::step::DispatchPhase::Pre,
2382 )
2383 .await;
2384 assert!(matches!(result.outcome, FieldOutcome::Deny { .. }));
2385 assert_eq!(
2386 result.taints,
2387 vec![TaintEvent {
2388 label: "before".into(),
2389 scopes: vec![TaintScope::Session],
2390 }]
2391 );
2392 }
2393
2394 struct PipePlugin {
2398 outcomes: std::collections::HashMap<String, PluginOutcome>,
2399 }
2400 #[async_trait]
2401 impl PluginInvoker for PipePlugin {
2402 async fn invoke(
2403 &self,
2404 name: &str,
2405 _bag: &AttributeBag,
2406 _invocation: PluginInvocation<'_>,
2407 ) -> Result<PluginOutcome, PluginError> {
2408 self.outcomes
2409 .get(name)
2410 .cloned()
2411 .ok_or_else(|| PluginError::NotFound(name.into()))
2412 }
2413 }
2414
2415 #[tokio::test]
2416 async fn pipeline_plugin_allow_continues() {
2417 let mut bag = AttributeBag::new();
2418 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2419 outcomes: std::collections::HashMap::from([(
2420 "noop".to_string(),
2421 PluginOutcome::allow(),
2422 )]),
2423 });
2424 let p = make_pipeline(vec![
2425 Stage::Type(TypeCheck::Str),
2426 Stage::Plugin {
2427 name: "noop".into(),
2428 },
2429 Stage::Mask { keep_last: 4 },
2430 ]);
2431 let result = evaluate_pipeline(
2432 &p,
2433 &json!("123-45-6789"),
2434 &bag,
2435 &plugins,
2436 "compensation",
2437 crate::step::DispatchPhase::Pre,
2438 )
2439 .await;
2440 assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
2441 assert!(result.taints.is_empty());
2442 }
2443
2444 #[tokio::test]
2445 async fn pipeline_plugin_can_replace_value() {
2446 let mut bag = AttributeBag::new();
2447 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2448 outcomes: std::collections::HashMap::from([(
2449 "scrubber".to_string(),
2450 PluginOutcome {
2451 decision: Decision::Allow,
2452 taints: vec![TaintEvent {
2453 label: "PII".to_string(),
2454 scopes: vec![TaintScope::Session],
2455 }],
2456 modified_value: Some(json!("***scrubbed***")),
2457 },
2458 )]),
2459 });
2460 let p = make_pipeline(vec![Stage::Plugin {
2461 name: "scrubber".into(),
2462 }]);
2463 let result = evaluate_pipeline(
2464 &p,
2465 &json!("sensitive data"),
2466 &bag,
2467 &plugins,
2468 "notes",
2469 crate::step::DispatchPhase::Pre,
2470 )
2471 .await;
2472 assert_eq!(
2473 result.outcome,
2474 FieldOutcome::Replace(json!("***scrubbed***"))
2475 );
2476 assert_eq!(
2477 result.taints,
2478 vec![TaintEvent {
2479 label: "PII".into(),
2480 scopes: vec![TaintScope::Session],
2481 }]
2482 );
2483 }
2484
2485 #[tokio::test]
2486 async fn pipeline_plugin_deny_halts() {
2487 let mut bag = AttributeBag::new();
2488 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2489 outcomes: std::collections::HashMap::from([(
2490 "guard".to_string(),
2491 PluginOutcome {
2492 decision: Decision::Deny {
2493 reason: Some("policy violation".into()),
2494 rule_source: "guard".into(),
2495 },
2496 taints: vec![],
2497 modified_value: None,
2498 },
2499 )]),
2500 });
2501 let p = make_pipeline(vec![
2502 Stage::Plugin {
2503 name: "guard".into(),
2504 },
2505 Stage::Mask { keep_last: 4 },
2507 ]);
2508 let result = evaluate_pipeline(
2509 &p,
2510 &json!("data"),
2511 &bag,
2512 &plugins,
2513 "payload",
2514 crate::step::DispatchPhase::Pre,
2515 )
2516 .await;
2517 match result.outcome {
2518 FieldOutcome::Deny {
2519 reason,
2520 stage_index,
2521 } => {
2522 assert_eq!(reason, "policy violation");
2523 assert_eq!(stage_index, 0);
2524 },
2525 other => panic!("expected Deny, got {:?}", other),
2526 }
2527 }
2528
2529 #[tokio::test]
2530 async fn pipeline_plugin_missing_fails_closed() {
2531 let mut bag = AttributeBag::new();
2532 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
2533 outcomes: Default::default(),
2534 });
2535 let p = make_pipeline(vec![Stage::Plugin {
2536 name: "missing".into(),
2537 }]);
2538 let result = evaluate_pipeline(
2539 &p,
2540 &json!("data"),
2541 &bag,
2542 &plugins,
2543 "payload",
2544 crate::step::DispatchPhase::Pre,
2545 )
2546 .await;
2547 match result.outcome {
2548 FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")),
2549 other => panic!("expected Deny on missing plugin, got {:?}", other),
2550 }
2551 }
2552
2553 #[test]
2558 fn exists_distinguishes_missing_from_falsy() {
2559 let mut bag = AttributeBag::new();
2560 bag.set("args.flag", false);
2561 assert!(!eval_condition(
2563 &Condition::IsTrue {
2564 key: "args.flag".into()
2565 },
2566 &bag
2567 ));
2568 assert!(eval_condition(
2569 &Condition::Exists {
2570 key: "args.flag".into()
2571 },
2572 &bag
2573 ));
2574 assert!(!eval_condition(
2576 &Condition::Exists {
2577 key: "args.nonexistent".into()
2578 },
2579 &bag
2580 ));
2581 }
2582
2583 #[test]
2584 fn in_set_member_and_non_member() {
2585 let mut bag = AttributeBag::new();
2586 bag.set("subject.type", "user");
2587 bag.set(
2588 "allowed_types",
2589 std::collections::HashSet::from(["user".to_string(), "service".to_string()]),
2590 );
2591
2592 assert!(eval_condition(
2593 &Condition::InSet {
2594 value_key: "subject.type".into(),
2595 set_key: "allowed_types".into(),
2596 negate: false,
2597 },
2598 &bag
2599 ));
2600
2601 bag.set("subject.type", "agent");
2602 assert!(!eval_condition(
2603 &Condition::InSet {
2604 value_key: "subject.type".into(),
2605 set_key: "allowed_types".into(),
2606 negate: false,
2607 },
2608 &bag
2609 ));
2610 }
2611
2612 #[test]
2613 fn in_set_negate() {
2614 let mut bag = AttributeBag::new();
2615 bag.set("subject.type", "agent");
2616 bag.set(
2617 "blocked_types",
2618 std::collections::HashSet::from(["service".to_string()]),
2619 );
2620
2621 assert!(eval_condition(
2623 &Condition::InSet {
2624 value_key: "subject.type".into(),
2625 set_key: "blocked_types".into(),
2626 negate: true,
2627 },
2628 &bag
2629 ));
2630 }
2631
2632 #[test]
2633 fn in_set_missing_keys_resolve_to_false() {
2634 let mut bag = AttributeBag::new();
2635 assert!(!eval_condition(
2638 &Condition::InSet {
2639 value_key: "x".into(),
2640 set_key: "y".into(),
2641 negate: false,
2642 },
2643 &bag
2644 ));
2645 assert!(eval_condition(
2646 &Condition::InSet {
2647 value_key: "x".into(),
2648 set_key: "y".into(),
2649 negate: true,
2650 },
2651 &bag
2652 ));
2653 }
2654
2655 #[test]
2656 fn always_evaluates_true() {
2657 let mut bag = AttributeBag::new();
2658 assert!(eval_expression(&Expression::Always, &bag));
2659 }
2660
2661 #[test]
2662 fn always_rule_unconditional_deny() {
2663 let mut bag = AttributeBag::new();
2664 let r = Rule {
2665 condition: Expression::Always,
2666 effects: vec![Effect::Deny {
2667 reason: Some("unconditional".into()),
2668 code: None,
2669 }],
2670 source: "test".into(),
2671 };
2672 match evaluate_rules(&[r], &bag) {
2673 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")),
2674 d => panic!("expected Deny, got {:?}", d),
2675 }
2676 }
2677
2678 use crate::step::{
2683 PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation,
2684 PluginInvoker, PluginOutcome,
2685 };
2686 use async_trait::async_trait;
2687
2688 struct FakePdp {
2692 decision: Decision,
2693 }
2694 #[async_trait]
2695 impl PdpResolver for FakePdp {
2696 fn dialect(&self) -> PdpDialect {
2697 PdpDialect::Cedar
2698 }
2699 async fn evaluate(
2700 &self,
2701 _call: &PdpCall,
2702 _bag: &AttributeBag,
2703 ) -> Result<PdpDecision, PdpError> {
2704 Ok(PdpDecision {
2705 decision: self.decision.clone(),
2706 diagnostics: vec![],
2707 })
2708 }
2709 }
2710
2711 struct ErroringPdp;
2713 #[async_trait]
2714 impl PdpResolver for ErroringPdp {
2715 fn dialect(&self) -> PdpDialect {
2716 PdpDialect::Cedar
2717 }
2718 async fn evaluate(
2719 &self,
2720 _call: &PdpCall,
2721 _bag: &AttributeBag,
2722 ) -> Result<PdpDecision, PdpError> {
2723 Err(PdpError::Dispatch("simulated PDP outage".into()))
2724 }
2725 }
2726
2727 struct DenyingElicitor;
2730 #[async_trait]
2731 impl crate::step::ElicitationInvoker for DenyingElicitor {
2732 async fn dispatch(
2733 &self,
2734 _step: &crate::step::ElicitStep,
2735 resolved_from: &str,
2736 ) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
2737 Ok(crate::step::ElicitationDispatch {
2738 id: "deny-1".into(),
2739 approver: Some(resolved_from.to_string()),
2740 intent_id: None,
2741 expires_at: None,
2742 })
2743 }
2744 async fn check(
2745 &self,
2746 _step: &crate::step::ElicitStep,
2747 _id: &str,
2748 ) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
2749 Ok(crate::step::ElicitationStatus::Resolved {
2750 outcome: crate::step::ElicitationOutcome::Denied,
2751 })
2752 }
2753 async fn validate(
2754 &self,
2755 _step: &crate::step::ElicitStep,
2756 _id: &str,
2757 ) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
2758 unreachable!("validate must not run on a denied elicitation")
2760 }
2761 }
2762
2763 struct StillPendingElicitor;
2766 #[async_trait]
2767 impl crate::step::ElicitationInvoker for StillPendingElicitor {
2768 async fn dispatch(
2769 &self,
2770 _step: &crate::step::ElicitStep,
2771 resolved_from: &str,
2772 ) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
2773 Ok(crate::step::ElicitationDispatch {
2774 id: "pending-1".into(),
2775 approver: Some(resolved_from.to_string()),
2776 intent_id: Some("intent-xyz".into()),
2777 expires_at: Some("2026-12-31T00:00:00Z".into()),
2778 })
2779 }
2780 async fn check(
2781 &self,
2782 _step: &crate::step::ElicitStep,
2783 _id: &str,
2784 ) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
2785 Ok(crate::step::ElicitationStatus::Pending)
2786 }
2787 async fn validate(
2788 &self,
2789 _step: &crate::step::ElicitStep,
2790 _id: &str,
2791 ) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
2792 unreachable!("validate must not run while pending")
2793 }
2794 }
2795
2796 struct FakePlugin {
2798 decisions: std::collections::HashMap<String, Decision>,
2799 }
2800 #[async_trait]
2801 impl PluginInvoker for FakePlugin {
2802 async fn invoke(
2803 &self,
2804 name: &str,
2805 _bag: &AttributeBag,
2806 _invocation: PluginInvocation<'_>,
2807 ) -> Result<PluginOutcome, PluginError> {
2808 match self.decisions.get(name) {
2809 Some(d) => Ok(PluginOutcome {
2810 decision: d.clone(),
2811 taints: vec![],
2812 modified_value: None,
2813 }),
2814 None => Err(PluginError::NotFound(name.into())),
2815 }
2816 }
2817 }
2818
2819 struct NullPlugins;
2821 #[async_trait]
2822 impl PluginInvoker for NullPlugins {
2823 async fn invoke(
2824 &self,
2825 name: &str,
2826 _bag: &AttributeBag,
2827 _invocation: PluginInvocation<'_>,
2828 ) -> Result<PluginOutcome, PluginError> {
2829 Err(PluginError::NotFound(name.into()))
2830 }
2831 }
2832
2833 fn pdp_step(decision_diagnostic_label: &str) -> Effect {
2834 Effect::Pdp {
2835 call: PdpCall {
2836 dialect: PdpDialect::Cedar,
2837 args: serde_yaml::Value::String(decision_diagnostic_label.into()),
2838 },
2839 on_deny: vec![],
2840 on_allow: vec![],
2841 }
2842 }
2843
2844 #[tokio::test]
2845 async fn steps_rule_only_path() {
2846 let mut bag = AttributeBag::new();
2847 let steps = vec![Effect::When {
2848 condition: Expression::Always,
2849 body: vec![Effect::Allow],
2850 source: "test".into(),
2851 }];
2852 let r = evaluate_effects(
2853 &steps,
2854 &mut bag,
2855 &(Arc::new(FakePdp {
2856 decision: Decision::Allow,
2857 }) as Arc<dyn PdpResolver>),
2858 &null_plugins(),
2859 &noop_delegations(),
2860 &noop_elicitations(),
2861 crate::step::DispatchPhase::Pre,
2862 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2863 )
2864 .await;
2865 assert_eq!(r.decision, Decision::Allow);
2866 }
2867
2868 #[tokio::test]
2869 async fn pdp_allow_continues() {
2870 let mut bag = AttributeBag::new();
2871 let steps = vec![pdp_step("dummy")];
2872 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2873 decision: Decision::Allow,
2874 });
2875 assert_eq!(
2876 evaluate_effects(
2877 &steps,
2878 &mut bag,
2879 &pdp,
2880 &null_plugins(),
2881 &noop_delegations(),
2882 &noop_elicitations(),
2883 crate::step::DispatchPhase::Pre,
2884 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
2885 )
2886 .await
2887 .decision,
2888 Decision::Allow,
2889 );
2890 }
2891
2892 #[tokio::test]
2893 async fn pdp_deny_returns_deny() {
2894 let mut bag = AttributeBag::new();
2895 let steps = vec![pdp_step("dummy")];
2896 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2897 decision: Decision::Deny {
2898 reason: Some("forbidden".into()),
2899 rule_source: "pdp".into(),
2900 },
2901 });
2902 match evaluate_effects(
2903 &steps,
2904 &mut bag,
2905 &pdp,
2906 &null_plugins(),
2907 &noop_delegations(),
2908 &noop_elicitations(),
2909 crate::step::DispatchPhase::Pre,
2910 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2911 )
2912 .await
2913 .decision
2914 {
2915 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")),
2916 d => panic!("expected Deny, got {:?}", d),
2917 }
2918 }
2919
2920 #[tokio::test]
2921 async fn pdp_on_deny_reaction_can_override_reason() {
2922 let mut bag = AttributeBag::new();
2925 let steps = vec![Effect::Pdp {
2926 call: PdpCall {
2927 dialect: PdpDialect::Cedar,
2928 args: serde_yaml::Value::Null,
2929 },
2930 on_deny: vec![Effect::When {
2931 condition: Expression::Always,
2932 body: vec![Effect::Deny {
2933 reason: Some("reaction took over".into()),
2934 code: None,
2935 }],
2936 source: "on_deny[0]".into(),
2937 }],
2938 on_allow: vec![],
2939 }];
2940 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2941 decision: Decision::Deny {
2942 reason: Some("pdp original".into()),
2943 rule_source: "p".into(),
2944 },
2945 });
2946 match evaluate_effects(
2947 &steps,
2948 &mut bag,
2949 &pdp,
2950 &null_plugins(),
2951 &noop_delegations(),
2952 &noop_elicitations(),
2953 crate::step::DispatchPhase::Pre,
2954 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
2955 )
2956 .await
2957 .decision
2958 {
2959 Decision::Deny {
2960 reason,
2961 rule_source,
2962 } => {
2963 assert_eq!(reason.as_deref(), Some("reaction took over"));
2964 assert_eq!(rule_source, "on_deny[0]");
2965 },
2966 d => panic!("expected Deny, got {:?}", d),
2967 }
2968 }
2969
2970 #[tokio::test]
2971 async fn pdp_on_allow_can_deny() {
2972 let mut bag = AttributeBag::new();
2975 let steps = vec![Effect::Pdp {
2976 call: PdpCall {
2977 dialect: PdpDialect::Cedar,
2978 args: serde_yaml::Value::Null,
2979 },
2980 on_deny: vec![],
2981 on_allow: vec![Effect::When {
2982 condition: Expression::Always,
2983 body: vec![Effect::Deny {
2984 reason: Some("reaction veto".into()),
2985 code: None,
2986 }],
2987 source: "on_allow[0]".into(),
2988 }],
2989 }];
2990 let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
2991 decision: Decision::Allow,
2992 });
2993 match evaluate_effects(
2994 &steps,
2995 &mut bag,
2996 &pdp,
2997 &null_plugins(),
2998 &noop_delegations(),
2999 &noop_elicitations(),
3000 crate::step::DispatchPhase::Pre,
3001 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3002 )
3003 .await
3004 .decision
3005 {
3006 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")),
3007 d => panic!("expected Deny, got {:?}", d),
3008 }
3009 }
3010
3011 #[tokio::test]
3012 async fn pdp_error_is_fail_closed() {
3013 let mut bag = AttributeBag::new();
3014 let steps = vec![pdp_step("dummy")];
3015 match evaluate_effects(
3016 &steps,
3017 &mut bag,
3018 &(Arc::new(ErroringPdp) as Arc<dyn PdpResolver>),
3019 &null_plugins(),
3020 &noop_delegations(),
3021 &noop_elicitations(),
3022 crate::step::DispatchPhase::Pre,
3023 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3024 )
3025 .await
3026 .decision
3027 {
3028 Decision::Deny { reason, .. } => {
3029 assert!(reason.unwrap().contains("PDP error"));
3030 },
3031 d => panic!("expected Deny on PDP error, got {:?}", d),
3032 }
3033 }
3034
3035 #[tokio::test]
3036 async fn plugin_allow_continues_deny_halts() {
3037 let mut bag = AttributeBag::new();
3038 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
3039 decisions: std::collections::HashMap::from([
3040 ("ok_plugin".to_string(), Decision::Allow),
3041 (
3042 "blocking_plugin".to_string(),
3043 Decision::Deny {
3044 reason: Some("rate limit hit".into()),
3045 rule_source: "plugin".into(),
3046 },
3047 ),
3048 ]),
3049 });
3050
3051 let allow_only = vec![Effect::Plugin {
3052 name: "ok_plugin".into(),
3053 }];
3054 assert_eq!(
3055 evaluate_effects(
3056 &allow_only,
3057 &mut bag,
3058 &(Arc::new(FakePdp {
3059 decision: Decision::Allow
3060 }) as Arc<dyn PdpResolver>),
3061 &plugins,
3062 &noop_delegations(),
3063 &noop_elicitations(),
3064 crate::step::DispatchPhase::Pre,
3065 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
3066 )
3067 .await
3068 .decision,
3069 Decision::Allow,
3070 );
3071
3072 let with_deny = vec![
3073 Effect::Plugin {
3074 name: "ok_plugin".into(),
3075 },
3076 Effect::Plugin {
3077 name: "blocking_plugin".into(),
3078 },
3079 ];
3080 match evaluate_effects(
3081 &with_deny,
3082 &mut bag,
3083 &(Arc::new(FakePdp {
3084 decision: Decision::Allow,
3085 }) as Arc<dyn PdpResolver>),
3086 &plugins,
3087 &noop_delegations(),
3088 &noop_elicitations(),
3089 crate::step::DispatchPhase::Pre,
3090 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3091 )
3092 .await
3093 .decision
3094 {
3095 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")),
3096 d => panic!("expected Deny from blocking_plugin, got {:?}", d),
3097 }
3098 }
3099
3100 #[tokio::test]
3101 async fn plugin_error_is_fail_closed() {
3102 let mut bag = AttributeBag::new();
3103 let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
3104 decisions: Default::default(),
3105 });
3106 let steps = vec![Effect::Plugin {
3107 name: "missing".into(),
3108 }];
3109 match evaluate_effects(
3110 &steps,
3111 &mut bag,
3112 &(Arc::new(FakePdp {
3113 decision: Decision::Allow,
3114 }) as Arc<dyn PdpResolver>),
3115 &plugins,
3116 &noop_delegations(),
3117 &noop_elicitations(),
3118 crate::step::DispatchPhase::Pre,
3119 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3120 )
3121 .await
3122 .decision
3123 {
3124 Decision::Deny {
3125 reason,
3126 rule_source,
3127 } => {
3128 assert!(reason.unwrap().contains("missing"));
3129 assert!(rule_source.contains("missing"));
3130 },
3131 d => panic!("expected Deny, got {:?}", d),
3132 }
3133 }
3134
3135 #[tokio::test]
3136 async fn taint_step_always_continues_and_accumulates() {
3137 let mut bag = AttributeBag::new();
3138 let steps = vec![
3139 Effect::Taint {
3140 label: "PII".into(),
3141 scopes: vec![crate::pipeline::TaintScope::Session],
3142 },
3143 Effect::When {
3145 condition: Expression::Always,
3146 body: vec![Effect::Deny {
3147 reason: Some("after taint".into()),
3148 code: None,
3149 }],
3150 source: "p[1]".into(),
3151 },
3152 ];
3153 let eval = evaluate_effects(
3154 &steps,
3155 &mut bag,
3156 &(Arc::new(FakePdp {
3157 decision: Decision::Allow,
3158 }) as Arc<dyn PdpResolver>),
3159 &null_plugins(),
3160 &noop_delegations(),
3161 &noop_elicitations(),
3162 crate::step::DispatchPhase::Pre,
3163 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3164 )
3165 .await;
3166 match eval.decision {
3167 Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")),
3168 d => panic!("expected Deny from rule after Taint, got {:?}", d),
3169 }
3170 assert_eq!(eval.taints.len(), 1);
3174 assert_eq!(eval.taints[0].label, "PII");
3175 assert_eq!(
3176 eval.taints[0].scopes,
3177 vec![crate::pipeline::TaintScope::Session]
3178 );
3179 }
3180
3181 #[tokio::test]
3184 async fn field_op_in_do_redacts_args_during_pre_phase() {
3185 let mut bag = AttributeBag::new();
3189 let stages = vec![Stage::Redact { condition: None }];
3191 let rule = Rule {
3192 condition: Expression::Always,
3193 effects: vec![Effect::FieldOp {
3194 path: "args.ssn".into(),
3195 stages,
3196 }],
3197 source: "demo.policy[0]".into(),
3198 };
3199 let steps = vec![Effect::from(rule)];
3200 let mut payload = crate::route::RoutePayload::new(json!({
3201 "ssn": "123-45-6789",
3202 "name": "Jane",
3203 }));
3204
3205 let eval = evaluate_effects(
3206 &steps,
3207 &mut bag,
3208 &(Arc::new(FakePdp {
3209 decision: Decision::Allow,
3210 }) as Arc<dyn PdpResolver>),
3211 &null_plugins(),
3212 &noop_delegations(),
3213 &noop_elicitations(),
3214 crate::step::DispatchPhase::Pre,
3215 &mut payload,
3216 )
3217 .await;
3218
3219 assert_eq!(eval.decision, Decision::Allow);
3220 assert!(eval.args_modified, "FieldOp should flag args_modified");
3221 assert_eq!(
3224 payload.args.get("ssn").and_then(|v| v.as_str()),
3225 Some("[REDACTED]")
3226 );
3227 assert_eq!(
3229 payload.args.get("name").and_then(|v| v.as_str()),
3230 Some("Jane")
3231 );
3232 }
3233
3234 #[tokio::test]
3235 async fn field_op_targeting_result_in_pre_phase_is_skipped() {
3236 let mut bag = AttributeBag::new();
3240 let stages = vec![Stage::Redact { condition: None }];
3241 let rule = Rule {
3242 condition: Expression::Always,
3243 effects: vec![Effect::FieldOp {
3244 path: "result.ssn".into(),
3245 stages,
3246 }],
3247 source: "demo.policy[0]".into(),
3248 };
3249 let mut payload = crate::route::RoutePayload::new(json!({}));
3250 let eval = evaluate_effects(
3251 &vec![Effect::from(rule)],
3252 &mut bag,
3253 &(Arc::new(FakePdp {
3254 decision: Decision::Allow,
3255 }) as Arc<dyn PdpResolver>),
3256 &null_plugins(),
3257 &noop_delegations(),
3258 &noop_elicitations(),
3259 crate::step::DispatchPhase::Pre,
3260 &mut payload,
3261 )
3262 .await;
3263 assert_eq!(eval.decision, Decision::Allow);
3264 assert!(!eval.args_modified);
3265 assert!(!eval.result_modified);
3266 }
3267
3268 #[tokio::test]
3269 async fn field_op_with_invalid_path_denies() {
3270 let mut bag = AttributeBag::new();
3274 let stages = vec![Stage::Redact { condition: None }];
3275 let rule = Rule {
3276 condition: Expression::Always,
3277 effects: vec![Effect::FieldOp {
3278 path: "ssn".into(), stages,
3280 }],
3281 source: "demo.policy[0]".into(),
3282 };
3283 let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"}));
3284 let eval = evaluate_effects(
3285 &vec![Effect::from(rule)],
3286 &mut bag,
3287 &(Arc::new(FakePdp {
3288 decision: Decision::Allow,
3289 }) as Arc<dyn PdpResolver>),
3290 &null_plugins(),
3291 &noop_delegations(),
3292 &noop_elicitations(),
3293 crate::step::DispatchPhase::Pre,
3294 &mut payload,
3295 )
3296 .await;
3297 match eval.decision {
3298 Decision::Deny {
3299 reason,
3300 rule_source,
3301 } => {
3302 assert!(reason.unwrap_or_default().contains("must start with"));
3303 assert_eq!(rule_source, "demo.policy[0]");
3304 },
3305 other => panic!("expected Deny, got {:?}", other),
3306 }
3307 }
3308
3309 #[tokio::test]
3312 async fn sequential_runs_effects_in_order_until_deny() {
3313 let mut bag = AttributeBag::new();
3317 let mut payload = crate::route::RoutePayload::new(json!({}));
3318
3319 let rule = Rule {
3320 condition: Expression::Always,
3321 effects: vec![Effect::Sequential(vec![
3322 Effect::Allow,
3323 Effect::Deny {
3324 reason: Some("blocked by sequential".into()),
3325 code: Some("seq.test".into()),
3326 },
3327 Effect::Allow, ])],
3329 source: "test.policy[0]".into(),
3330 };
3331
3332 let eval = evaluate_effects(
3333 &vec![Effect::from(rule)],
3334 &mut bag,
3335 &(Arc::new(FakePdp {
3336 decision: Decision::Allow,
3337 }) as Arc<dyn PdpResolver>),
3338 &null_plugins(),
3339 &noop_delegations(),
3340 &noop_elicitations(),
3341 crate::step::DispatchPhase::Pre,
3342 &mut payload,
3343 )
3344 .await;
3345
3346 match eval.decision {
3347 Decision::Deny {
3348 reason,
3349 rule_source,
3350 } => {
3351 assert_eq!(reason.as_deref(), Some("blocked by sequential"));
3352 assert_eq!(rule_source, "seq.test");
3355 },
3356 other => panic!("expected Deny, got {:?}", other),
3357 }
3358 }
3359
3360 #[tokio::test]
3361 async fn parallel_allows_when_no_branch_denies() {
3362 let mut bag = AttributeBag::new();
3364 let mut payload = crate::route::RoutePayload::new(json!({}));
3365
3366 let rule = Rule {
3367 condition: Expression::Always,
3368 effects: vec![Effect::Parallel(vec![
3369 Effect::Allow,
3370 Effect::Taint {
3371 label: "audit_branch".into(),
3372 scopes: vec![crate::pipeline::TaintScope::Session],
3373 },
3374 ])],
3375 source: "test.policy[0]".into(),
3376 };
3377
3378 let eval = evaluate_effects(
3379 &vec![Effect::from(rule)],
3380 &mut bag,
3381 &(Arc::new(FakePdp {
3382 decision: Decision::Allow,
3383 }) as Arc<dyn PdpResolver>),
3384 &null_plugins(),
3385 &noop_delegations(),
3386 &noop_elicitations(),
3387 crate::step::DispatchPhase::Pre,
3388 &mut payload,
3389 )
3390 .await;
3391
3392 assert_eq!(eval.decision, Decision::Allow);
3393 assert_eq!(eval.taints.len(), 1);
3395 assert_eq!(eval.taints[0].label, "audit_branch");
3396 }
3397
3398 #[tokio::test]
3399 async fn parallel_denies_when_any_branch_denies() {
3400 let mut bag = AttributeBag::new();
3402 let mut payload = crate::route::RoutePayload::new(json!({}));
3403
3404 let rule = Rule {
3405 condition: Expression::Always,
3406 effects: vec![Effect::Parallel(vec![
3407 Effect::Allow,
3408 Effect::Deny {
3409 reason: Some("branch 1 denied".into()),
3410 code: None,
3411 },
3412 ])],
3413 source: "test.policy[0]".into(),
3414 };
3415
3416 let eval = evaluate_effects(
3417 &vec![Effect::from(rule)],
3418 &mut bag,
3419 &(Arc::new(FakePdp {
3420 decision: Decision::Allow,
3421 }) as Arc<dyn PdpResolver>),
3422 &null_plugins(),
3423 &noop_delegations(),
3424 &noop_elicitations(),
3425 crate::step::DispatchPhase::Pre,
3426 &mut payload,
3427 )
3428 .await;
3429
3430 match eval.decision {
3431 Decision::Deny { reason, .. } => {
3432 assert_eq!(reason.as_deref(), Some("branch 1 denied"));
3433 },
3434 other => panic!("expected Deny, got {:?}", other),
3435 }
3436 }
3437
3438 #[tokio::test]
3439 async fn parallel_picks_first_index_halt_not_first_to_complete() {
3440 let mut bag = AttributeBag::new();
3444 let mut payload = crate::route::RoutePayload::new(json!({}));
3445
3446 let rule = Rule {
3447 condition: Expression::Always,
3448 effects: vec![Effect::Parallel(vec![
3449 Effect::Deny {
3450 reason: Some("idx-0".into()),
3451 code: None,
3452 },
3453 Effect::Deny {
3454 reason: Some("idx-1".into()),
3455 code: None,
3456 },
3457 ])],
3458 source: "test.policy[0]".into(),
3459 };
3460
3461 let eval = evaluate_effects(
3462 &vec![Effect::from(rule)],
3463 &mut bag,
3464 &(Arc::new(FakePdp {
3465 decision: Decision::Allow,
3466 }) as Arc<dyn PdpResolver>),
3467 &null_plugins(),
3468 &noop_delegations(),
3469 &noop_elicitations(),
3470 crate::step::DispatchPhase::Pre,
3471 &mut payload,
3472 )
3473 .await;
3474
3475 match eval.decision {
3476 Decision::Deny { reason, .. } => {
3477 assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins");
3478 },
3479 other => panic!("expected Deny, got {:?}", other),
3480 }
3481 }
3482
3483 async fn run_elicit(
3490 effect: Effect,
3491 elicitations: &Arc<dyn crate::step::ElicitationInvoker>,
3492 bag: &mut AttributeBag,
3493 ) -> StepsEvaluation {
3494 if bag.get_string("user.manager").is_none() {
3495 bag.set("user.manager", "manager@corp.com");
3496 }
3497 evaluate_effects(
3498 &[effect],
3499 bag,
3500 &(Arc::new(FakePdp {
3501 decision: Decision::Allow,
3502 }) as Arc<dyn PdpResolver>),
3503 &null_plugins(),
3504 &noop_delegations(),
3505 elicitations,
3506 crate::step::DispatchPhase::Pre,
3507 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3508 )
3509 .await
3510 }
3511
3512 #[tokio::test]
3513 async fn elicit_auto_approve_allows_and_records_facts() {
3514 let mut bag = AttributeBag::new();
3515 let eval = run_elicit(elicit_effect(None, None), &auto_elicitations(), &mut bag).await;
3516 assert_eq!(eval.decision, Decision::Allow);
3517 assert_eq!(bag.get_string("elicitation.status"), Some("resolved"));
3519 assert_eq!(bag.get_string("elicitation.outcome"), Some("approved"));
3520 assert_eq!(
3521 bag.get_string("elicitation.approver"),
3522 Some("manager@corp.com")
3523 );
3524 assert_eq!(bag.get_string("elicitation.intent_id"), Some("auto-intent"));
3525 }
3526
3527 #[tokio::test]
3528 async fn elicit_scope_satisfied_allows() {
3529 let mut bag = AttributeBag::new();
3531 bag.set("args.amount", 100_i64);
3532 let eval = run_elicit(
3533 elicit_effect(Some("args.amount <= 25000"), None),
3534 &auto_elicitations(),
3535 &mut bag,
3536 )
3537 .await;
3538 assert_eq!(eval.decision, Decision::Allow);
3539 }
3540
3541 #[tokio::test]
3542 async fn elicit_scope_unsatisfied_denies_even_when_approved() {
3543 let mut bag = AttributeBag::new();
3546 bag.set("args.amount", 50_000_i64);
3547 let eval = run_elicit(
3548 elicit_effect(Some("args.amount <= 25000"), None),
3549 &auto_elicitations(),
3550 &mut bag,
3551 )
3552 .await;
3553 match eval.decision {
3554 Decision::Deny { reason, .. } => {
3555 assert!(
3556 reason
3557 .as_deref()
3558 .unwrap_or("")
3559 .contains("scope not satisfied"),
3560 "got: {:?}",
3561 reason
3562 );
3563 },
3564 d => panic!("expected scope Deny, got {:?}", d),
3565 }
3566 }
3567
3568 #[tokio::test]
3569 async fn elicit_noop_dispatch_fails_closed() {
3570 let mut bag = AttributeBag::new();
3572 let eval = run_elicit(elicit_effect(None, None), &noop_elicitations(), &mut bag).await;
3573 match eval.decision {
3574 Decision::Deny {
3575 reason,
3576 rule_source,
3577 } => {
3578 assert!(reason.as_deref().unwrap_or("").contains("dispatch failed"));
3579 assert_eq!(rule_source, "route.test.policy[0]");
3580 },
3581 d => panic!("expected fail-closed Deny, got {:?}", d),
3582 }
3583 }
3584
3585 #[tokio::test]
3586 async fn elicit_on_error_continue_allows_on_dispatch_failure() {
3587 let mut bag = AttributeBag::new();
3589 let eval = run_elicit(
3590 elicit_effect(None, Some("continue")),
3591 &noop_elicitations(),
3592 &mut bag,
3593 )
3594 .await;
3595 assert_eq!(eval.decision, Decision::Allow);
3596 }
3597
3598 #[tokio::test]
3599 async fn elicit_denied_halts_regardless_of_on_error() {
3600 let denier: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(DenyingElicitor);
3603 let mut bag = AttributeBag::new();
3604 let eval = run_elicit(elicit_effect(None, Some("continue")), &denier, &mut bag).await;
3605 match eval.decision {
3606 Decision::Deny { reason, .. } => {
3607 assert!(reason
3608 .as_deref()
3609 .unwrap_or("")
3610 .contains("denied by approver"));
3611 },
3612 d => panic!("expected denial Deny, got {:?}", d),
3613 }
3614 assert_eq!(bag.get_string("elicitation.outcome"), Some("denied"));
3615 }
3616
3617 #[tokio::test]
3618 async fn elicit_pending_suspends_not_denies() {
3619 let pending: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(StillPendingElicitor);
3623 let mut bag = AttributeBag::new();
3624 let eval = run_elicit(elicit_effect(None, None), &pending, &mut bag).await;
3625
3626 assert_eq!(eval.decision, Decision::Allow, "pending is not a deny");
3627 let bundle = eval.pending.expect("pending bundle carried out");
3628 assert_eq!(bundle.id, "pending-1");
3629 assert_eq!(bundle.plugin_name, "manager-approver");
3630 assert_eq!(bundle.approver.as_deref(), Some("manager@corp.com"));
3631 assert_eq!(bundle.intent_id.as_deref(), Some("intent-xyz"));
3632 assert_eq!(bundle.channel.as_deref(), Some("ciba"));
3633 assert_eq!(bundle.expires_at.as_deref(), Some("2026-12-31T00:00:00Z"));
3634 assert_eq!(bundle.source, "route.test.policy[0]");
3635 assert_eq!(bag.get_string("elicitation.status"), Some("pending"));
3637 }
3638
3639 #[tokio::test]
3640 async fn elicit_pending_even_with_on_error_continue() {
3641 let pending: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(StillPendingElicitor);
3645 let mut bag = AttributeBag::new();
3646 let eval = run_elicit(elicit_effect(None, Some("continue")), &pending, &mut bag).await;
3647 assert_eq!(eval.decision, Decision::Allow);
3648 assert!(
3649 eval.pending.is_some(),
3650 "pending must survive on_error=continue"
3651 );
3652 }
3653
3654 #[tokio::test]
3655 async fn elicit_pending_short_circuits_later_effects() {
3656 let pending: Arc<dyn crate::step::ElicitationInvoker> = Arc::new(StillPendingElicitor);
3660 let mut bag = AttributeBag::new();
3661 bag.set("user.manager", "manager@corp.com");
3662 let effects = vec![elicit_effect(None, None), deny("should not run")];
3663 let eval = evaluate_effects(
3664 &effects,
3665 &mut bag,
3666 &(Arc::new(FakePdp {
3667 decision: Decision::Allow,
3668 }) as Arc<dyn PdpResolver>),
3669 &null_plugins(),
3670 &noop_delegations(),
3671 &pending,
3672 crate::step::DispatchPhase::Pre,
3673 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3674 )
3675 .await;
3676 assert_eq!(eval.decision, Decision::Allow, "trailing deny must not run");
3677 assert!(eval.pending.is_some());
3678 }
3679
3680 #[tokio::test]
3681 async fn elicit_unresolved_from_attribute_fails_closed() {
3682 let mut bag = AttributeBag::new(); let eval = evaluate_effects(
3687 &[elicit_effect(None, None)],
3688 &mut bag,
3689 &(Arc::new(FakePdp {
3690 decision: Decision::Allow,
3691 }) as Arc<dyn PdpResolver>),
3692 &null_plugins(),
3693 &noop_delegations(),
3694 &auto_elicitations(),
3695 crate::step::DispatchPhase::Pre,
3696 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3697 )
3698 .await;
3699 match eval.decision {
3700 Decision::Deny { reason, .. } => {
3701 let r = reason.as_deref().unwrap_or("");
3702 assert!(r.contains("did not resolve"), "got: {r}");
3703 assert!(r.contains("user.manager"), "got: {r}");
3704 },
3705 d => panic!(
3706 "expected fail-closed Deny on unresolved `from`, got {:?}",
3707 d
3708 ),
3709 }
3710 assert!(bag.get_string("elicitation.id").is_none());
3712 }
3713
3714 #[tokio::test]
3715 async fn elicit_literal_from_passes_through_unresolved() {
3716 let mut bag = AttributeBag::new();
3719 let effect = Effect::Elicit(crate::step::ElicitStep {
3720 from: "alice@corp.com".into(),
3721 ..match elicit_effect(None, None) {
3722 Effect::Elicit(e) => e,
3723 _ => unreachable!(),
3724 }
3725 });
3726 let eval = evaluate_effects(
3727 &[effect],
3728 &mut bag,
3729 &(Arc::new(FakePdp {
3730 decision: Decision::Allow,
3731 }) as Arc<dyn PdpResolver>),
3732 &null_plugins(),
3733 &noop_delegations(),
3734 &auto_elicitations(),
3735 crate::step::DispatchPhase::Pre,
3736 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3737 )
3738 .await;
3739 assert_eq!(eval.decision, Decision::Allow);
3740 assert_eq!(
3742 bag.get_string("elicitation.approver"),
3743 Some("alice@corp.com")
3744 );
3745 }
3746
3747 #[test]
3748 fn looks_like_attribute_ref_classification() {
3749 assert!(looks_like_attribute_ref("user.manager"));
3751 assert!(looks_like_attribute_ref("claim.manager"));
3752 assert!(looks_like_attribute_ref("user.sub"));
3753 assert!(!looks_like_attribute_ref("alice@corp.com"));
3755 assert!(!looks_like_attribute_ref("Alice Smith"));
3756 assert!(!looks_like_attribute_ref("alice"));
3757 assert!(!looks_like_attribute_ref(""));
3758 }
3759}