use std::sync::Arc;
use crate::attributes::AttributeBag;
use crate::evaluator::{evaluate_effects, evaluate_pipeline, Decision, FieldOutcome};
use crate::pipeline::TaintEvent;
use crate::rules::CompiledRoute;
use crate::step::{
DelegationInvoker, DispatchPhase, ElicitationInvoker, PdpResolver, PluginInvoker,
};
#[derive(Debug, Clone)]
pub struct RoutePayload {
pub args: serde_json::Value,
pub result: Option<serde_json::Value>,
}
impl RoutePayload {
pub fn new(args: serde_json::Value) -> Self {
Self { args, result: None }
}
pub fn with_result(args: serde_json::Value, result: serde_json::Value) -> Self {
Self {
args,
result: Some(result),
}
}
}
#[derive(Debug, Clone)]
pub struct RouteDecision {
pub decision: Decision,
pub taints: Vec<TaintEvent>,
pub args_modified: bool,
pub result_modified: bool,
pub pending: Option<crate::step::PendingElicitation>,
}
pub async fn evaluate_pre(
route: &CompiledRoute,
bag: &mut AttributeBag,
payload: &mut RoutePayload,
pdp: &Arc<dyn PdpResolver>,
plugins: &Arc<dyn PluginInvoker>,
delegations: &Arc<dyn DelegationInvoker>,
elicitations: &Arc<dyn ElicitationInvoker>,
) -> RouteDecision {
let mut taints: Vec<TaintEvent> = Vec::new();
let mut args_modified = false;
for rule in &route.args {
let Some(current) = get_dotted(&payload.args, &rule.field).cloned() else {
continue; };
let eval = evaluate_pipeline(
&rule.pipeline,
¤t,
bag,
plugins,
&rule.field,
DispatchPhase::Pre,
)
.await;
taints.extend(eval.taints);
match eval.outcome {
FieldOutcome::Pass => {},
FieldOutcome::Replace(new_val) => {
if set_dotted(&mut payload.args, &rule.field, new_val) {
args_modified = true;
}
},
FieldOutcome::Omit => {
if remove_dotted(&mut payload.args, &rule.field) {
args_modified = true;
}
},
FieldOutcome::Deny {
reason,
stage_index: _,
} => {
return RouteDecision {
decision: Decision::Deny {
reason: Some(reason),
rule_source: rule.source.clone(),
},
taints,
args_modified,
result_modified: false,
pending: None,
};
},
}
}
let policy_eval = evaluate_effects(
&route.policy,
bag,
pdp,
plugins,
delegations,
elicitations,
DispatchPhase::Pre,
payload,
)
.await;
args_modified |= policy_eval.args_modified;
taints.extend(policy_eval.taints);
RouteDecision {
decision: policy_eval.decision,
taints,
args_modified,
result_modified: false,
pending: policy_eval.pending,
}
}
pub async fn evaluate_post(
route: &CompiledRoute,
bag: &mut AttributeBag,
payload: &mut RoutePayload,
pdp: &Arc<dyn PdpResolver>,
plugins: &Arc<dyn PluginInvoker>,
delegations: &Arc<dyn DelegationInvoker>,
elicitations: &Arc<dyn ElicitationInvoker>,
) -> RouteDecision {
let mut taints: Vec<TaintEvent> = Vec::new();
let mut result_modified = false;
if let Some(result) = payload.result.as_mut() {
for rule in &route.result {
let Some(current) = get_dotted(result, &rule.field).cloned() else {
continue;
};
let eval = evaluate_pipeline(
&rule.pipeline,
¤t,
bag,
plugins,
&rule.field,
DispatchPhase::Post,
)
.await;
taints.extend(eval.taints);
match eval.outcome {
FieldOutcome::Pass => {},
FieldOutcome::Replace(new_val) => {
if set_dotted(result, &rule.field, new_val) {
result_modified = true;
}
},
FieldOutcome::Omit => {
if remove_dotted(result, &rule.field) {
result_modified = true;
}
},
FieldOutcome::Deny {
reason,
stage_index: _,
} => {
return RouteDecision {
decision: Decision::Deny {
reason: Some(reason),
rule_source: rule.source.clone(),
},
taints,
args_modified: false,
result_modified,
pending: None,
};
},
}
}
}
let post_eval = evaluate_effects(
&route.post_policy,
bag,
pdp,
plugins,
delegations,
elicitations,
DispatchPhase::Post,
payload,
)
.await;
result_modified |= post_eval.result_modified;
taints.extend(post_eval.taints);
RouteDecision {
decision: post_eval.decision,
taints,
args_modified: false,
result_modified,
pending: post_eval.pending,
}
}
pub async fn evaluate_route(
route: &CompiledRoute,
bag: &mut AttributeBag,
payload: &mut RoutePayload,
pdp: &Arc<dyn PdpResolver>,
plugins: &Arc<dyn PluginInvoker>,
delegations: &Arc<dyn DelegationInvoker>,
elicitations: &Arc<dyn ElicitationInvoker>,
) -> RouteDecision {
let pre = evaluate_pre(route, bag, payload, pdp, plugins, delegations, elicitations).await;
if matches!(pre.decision, Decision::Deny { .. }) || pre.pending.is_some() {
return pre;
}
let post = evaluate_post(route, bag, payload, pdp, plugins, delegations, elicitations).await;
let mut taints = pre.taints;
taints.extend(post.taints);
RouteDecision {
decision: post.decision,
taints,
args_modified: pre.args_modified,
result_modified: post.result_modified,
pending: post.pending,
}
}
pub(crate) fn get_dotted<'a>(
root: &'a serde_json::Value,
path: &str,
) -> Option<&'a serde_json::Value> {
let mut cur = root;
for seg in path.split('.') {
cur = cur.get(seg)?;
}
Some(cur)
}
pub(crate) fn set_dotted(
root: &mut serde_json::Value,
path: &str,
value: serde_json::Value,
) -> bool {
let parts: Vec<&str> = path.split('.').collect();
let (leaf, parents) = match parts.split_last() {
Some(x) => x,
None => return false,
};
let mut cur = root;
for seg in parents {
let Some(next) = cur.get_mut(*seg) else {
return false;
};
if !next.is_object() {
return false;
}
cur = next;
}
if let serde_json::Value::Object(map) = cur {
map.insert((*leaf).to_string(), value);
true
} else {
false
}
}
pub(crate) fn remove_dotted(root: &mut serde_json::Value, path: &str) -> bool {
let parts: Vec<&str> = path.split('.').collect();
let (leaf, parents) = match parts.split_last() {
Some(x) => x,
None => return false,
};
let mut cur = root;
for seg in parents {
let Some(next) = cur.get_mut(*seg) else {
return false;
};
if !next.is_object() {
return false;
}
cur = next;
}
if let serde_json::Value::Object(map) = cur {
map.remove(*leaf).is_some()
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::{FieldRule, Pipeline, Stage, TaintScope, TypeCheck};
use crate::rules::{Effect, Expression, Rule};
use crate::step::{
NoopDelegationInvoker, NoopElicitationInvoker, PdpCall, PdpDecision, PdpDialect, PdpError,
PluginError, PluginInvocation, PluginOutcome,
};
use async_trait::async_trait;
use serde_json::json;
struct AllowPdp;
#[async_trait]
impl PdpResolver for AllowPdp {
fn dialect(&self) -> PdpDialect {
PdpDialect::Cedar
}
async fn evaluate(
&self,
_call: &PdpCall,
_bag: &AttributeBag,
) -> Result<PdpDecision, PdpError> {
Ok(PdpDecision {
decision: Decision::Allow,
diagnostics: vec![],
})
}
}
struct NoPlugins;
#[async_trait]
impl PluginInvoker for NoPlugins {
async fn invoke(
&self,
name: &str,
_bag: &AttributeBag,
_invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError> {
Err(PluginError::NotFound(name.into()))
}
}
struct PendingElicitor;
#[async_trait]
impl ElicitationInvoker for PendingElicitor {
async fn dispatch(
&self,
_step: &crate::step::ElicitStep,
_resolved_from: &str,
) -> Result<crate::step::ElicitationDispatch, crate::step::ElicitationError> {
Ok(crate::step::ElicitationDispatch {
id: "elic-route-1".into(),
approver: None,
intent_id: None,
expires_at: None,
})
}
async fn check(
&self,
_step: &crate::step::ElicitStep,
_id: &str,
) -> Result<crate::step::ElicitationStatus, crate::step::ElicitationError> {
Ok(crate::step::ElicitationStatus::Pending)
}
async fn validate(
&self,
_step: &crate::step::ElicitStep,
_id: &str,
) -> Result<crate::step::ElicitationValidation, crate::step::ElicitationError> {
unreachable!("validate must not run while pending")
}
}
fn pdp_arc() -> Arc<dyn PdpResolver> {
Arc::new(AllowPdp)
}
fn plugins() -> Arc<dyn PluginInvoker> {
Arc::new(NoPlugins)
}
fn delegations() -> Arc<dyn DelegationInvoker> {
Arc::new(NoopDelegationInvoker)
}
fn elicitations() -> Arc<dyn ElicitationInvoker> {
Arc::new(NoopElicitationInvoker)
}
fn field_rule(field: &str, stages: Vec<Stage>) -> FieldRule {
FieldRule {
field: field.into(),
pipeline: Pipeline { stages },
source: format!("test.{}", field),
}
}
fn deny_rule(source: &str, reason: &str) -> Rule {
Rule::single(
Expression::Always,
Effect::Deny {
reason: Some(reason.into()),
code: None,
},
source,
)
}
#[tokio::test]
async fn empty_route_allows() {
let route = CompiledRoute::new("noop");
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({}));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(!r.args_modified);
assert!(!r.result_modified);
assert!(r.taints.is_empty());
}
#[tokio::test]
async fn pending_elicitation_suspends_route_and_skips_post() {
let mut route = CompiledRoute::new("payroll");
route.policy.push(Effect::Elicit(crate::step::ElicitStep {
kind: crate::step::ElicitKind::Approval,
plugin_name: "manager-approver".into(),
channel: Some("ciba".into()),
from: "user.manager".into(),
purpose: None,
scope: None,
timeout: None,
config_override: None,
on_error: None,
source: "payroll.policy[0]".into(),
}));
route
.result
.push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }]));
let elicitor: Arc<dyn ElicitationInvoker> = Arc::new(PendingElicitor);
let mut bag = AttributeBag::new();
bag.set("user.manager", "manager@corp.com");
let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789" }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitor,
)
.await;
assert_eq!(r.decision, Decision::Allow, "pending is not a deny");
let bundle = r.pending.expect("route surfaced the pending bundle");
assert_eq!(bundle.id, "elic-route-1");
assert_eq!(bundle.plugin_name, "manager-approver");
assert!(!r.result_modified);
assert_eq!(
payload.result.as_ref().unwrap()["ssn"],
json!("123-45-6789")
);
}
#[tokio::test]
async fn args_pipeline_mutates_payload() {
let mut route = CompiledRoute::new("ping");
route
.args
.push(field_rule("ssn", vec![Stage::Mask { keep_last: 4 }]));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({ "ssn": "123-45-6789" }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(r.args_modified);
assert_eq!(payload.args["ssn"], json!("*******6789"));
}
#[tokio::test]
async fn args_deny_halts_route() {
let mut route = CompiledRoute::new("ping");
route.args.push(field_rule(
"amount",
vec![
Stage::Type(TypeCheck::Int),
Stage::Range {
min: Some(0),
max: Some(100),
},
],
));
route
.policy
.push(Effect::from(deny_rule("policy[0]", "policy denied too")));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({ "amount": 200 }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
match r.decision {
Decision::Deny { rule_source, .. } => {
assert!(
rule_source.contains("amount"),
"expected args rule source, got {}",
rule_source
);
},
d => panic!("expected Deny from args phase, got {:?}", d),
}
}
#[tokio::test]
async fn args_missing_field_is_skipped() {
let mut route = CompiledRoute::new("ping");
route.args.push(field_rule(
"compensation",
vec![Stage::Type(TypeCheck::Int)],
));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({ "other_field": 5 }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(!r.args_modified);
}
#[tokio::test]
async fn args_omit_drops_field() {
let mut route = CompiledRoute::new("ping");
route.args.push(field_rule("secret", vec![Stage::Omit]));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({ "secret": "xyz", "keep": 1 }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(r.args_modified);
assert!(payload.args.get("secret").is_none());
assert_eq!(payload.args["keep"], json!(1));
}
#[tokio::test]
async fn policy_deny_halts_before_result() {
let mut route = CompiledRoute::new("ping");
route
.policy
.push(Effect::from(deny_rule("policy[0]", "blocked")));
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
match r.decision {
Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "policy[0]"),
d => panic!("expected policy deny, got {:?}", d),
}
assert!(!r.result_modified);
assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("123"));
}
#[tokio::test]
async fn result_phase_skipped_when_no_response() {
let mut route = CompiledRoute::new("ping");
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({})); let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(!r.result_modified);
}
#[tokio::test]
async fn result_pipeline_redacts_field() {
let mut route = CompiledRoute::new("ping");
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
let mut bag = AttributeBag::new();
let mut payload =
RoutePayload::with_result(json!({}), json!({ "ssn": "123-45-6789", "name": "alice" }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(r.result_modified);
let result = payload.result.as_ref().unwrap();
assert_eq!(result["ssn"], json!("[REDACTED]"));
assert_eq!(result["name"], json!("alice"));
}
#[tokio::test]
async fn taints_accumulate_across_phases() {
let mut route = CompiledRoute::new("ping");
route.args.push(field_rule(
"input",
vec![Stage::Taint {
label: "args_seen".into(),
scopes: vec![TaintScope::Session],
}],
));
route.result.push(field_rule(
"output",
vec![Stage::Taint {
label: "result_seen".into(),
scopes: vec![TaintScope::Message],
}],
));
let mut bag = AttributeBag::new();
let mut payload =
RoutePayload::with_result(json!({ "input": "hello" }), json!({ "output": "world" }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
let labels: Vec<&str> = r.taints.iter().map(|t| t.label.as_str()).collect();
assert_eq!(labels, vec!["args_seen", "result_seen"]);
}
#[tokio::test]
async fn nested_field_path_resolves_and_writes() {
let mut route = CompiledRoute::new("ping");
route.args.push(field_rule(
"user.profile.ssn",
vec![Stage::Mask { keep_last: 4 }],
));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({
"user": { "profile": { "ssn": "123-45-6789", "name": "alice" } }
}));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(r.args_modified);
assert_eq!(payload.args["user"]["profile"]["ssn"], json!("*******6789"));
assert_eq!(payload.args["user"]["profile"]["name"], json!("alice"));
}
#[tokio::test]
async fn nested_field_missing_intermediate_is_skipped() {
let mut route = CompiledRoute::new("ping");
route.args.push(field_rule(
"user.profile.ssn",
vec![Stage::Mask { keep_last: 4 }],
));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::new(json!({ "user": { "name": "alice" } }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(!r.args_modified);
}
#[tokio::test]
async fn post_policy_runs_after_result() {
let mut route = CompiledRoute::new("ping");
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
route
.post_policy
.push(Effect::from(deny_rule("post_policy[0]", "after-the-fact")));
let mut bag = AttributeBag::new();
let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "123" }));
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
match r.decision {
Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "post_policy[0]"),
d => panic!("expected post_policy deny, got {:?}", d),
}
assert!(r.result_modified);
assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]"));
}
#[test]
fn dotted_get_simple_and_nested() {
let v = json!({ "a": { "b": { "c": 7 } } });
assert_eq!(get_dotted(&v, "a.b.c"), Some(&json!(7)));
assert_eq!(get_dotted(&v, "a.b"), Some(&json!({ "c": 7 })));
assert!(get_dotted(&v, "a.b.x").is_none());
assert!(get_dotted(&v, "missing").is_none());
}
#[test]
fn dotted_set_overwrites_leaf() {
let mut v = json!({ "a": { "b": 1 } });
assert!(set_dotted(&mut v, "a.b", json!(99)));
assert_eq!(v["a"]["b"], json!(99));
}
#[test]
fn dotted_set_does_not_create_missing_parents() {
let mut v = json!({});
assert!(!set_dotted(&mut v, "a.b", json!(1)));
assert_eq!(v, json!({}));
}
#[test]
fn dotted_remove_leaf() {
let mut v = json!({ "a": { "b": 1, "c": 2 } });
assert!(remove_dotted(&mut v, "a.b"));
assert_eq!(v, json!({ "a": { "c": 2 } }));
assert!(!remove_dotted(&mut v, "a.b"));
}
#[tokio::test]
async fn evaluate_pre_runs_args_and_policy_only() {
let mut route = CompiledRoute::new("test");
route
.args
.push(field_rule("id", vec![Stage::Mask { keep_last: 2 }]));
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
let mut payload =
RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" }));
let mut bag = AttributeBag::new();
let r = evaluate_pre(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(
r.args_modified,
"args mask stage should have rewritten the field"
);
assert!(!r.result_modified, "evaluate_pre must not touch result");
assert_eq!(payload.args["id"], json!("******GH"));
assert_eq!(
payload.result.as_ref().unwrap()["ssn"],
json!("555-12-3456")
);
}
#[tokio::test]
async fn evaluate_post_runs_result_and_post_policy_only() {
let mut route = CompiledRoute::new("test");
route
.args
.push(field_rule("id", vec![Stage::Mask { keep_last: 2 }]));
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
let mut payload =
RoutePayload::with_result(json!({ "id": "ABCDEFGH" }), json!({ "ssn": "555-12-3456" }));
let mut bag = AttributeBag::new();
let r = evaluate_post(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert_eq!(r.decision, Decision::Allow);
assert!(!r.args_modified, "evaluate_post must not touch args");
assert!(r.result_modified, "result redact should have fired");
assert_eq!(payload.args["id"], json!("ABCDEFGH"));
assert_eq!(payload.result.as_ref().unwrap()["ssn"], json!("[REDACTED]"));
}
#[tokio::test]
async fn evaluate_pre_deny_halts_before_policy() {
let mut route = CompiledRoute::new("test");
route
.args
.push(field_rule("id", vec![Stage::Type(TypeCheck::Uuid)]));
route.policy.push(Effect::from(Rule::single(
Expression::Always,
Effect::Deny {
reason: Some("policy_should_not_run".into()),
code: None,
},
"test.policy[0]",
)));
let mut payload = RoutePayload::new(json!({ "id": "not-a-uuid" }));
let mut bag = AttributeBag::new();
let r = evaluate_pre(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
match r.decision {
Decision::Deny { rule_source, .. } => {
assert!(
rule_source.contains("test.id"),
"args denial got source {}",
rule_source
);
},
d => panic!("expected args-side Deny, got {:?}", d),
}
}
#[tokio::test]
async fn evaluate_route_skips_post_on_pre_deny() {
let mut route = CompiledRoute::new("test");
route.policy.push(Effect::from(Rule::single(
Expression::Always,
Effect::Deny {
reason: Some("policy_deny".into()),
code: None,
},
"test.policy[0]",
)));
route
.result
.push(field_rule("ssn", vec![Stage::Redact { condition: None }]));
route.post_policy.push(Effect::Taint {
label: "should_not_emit".into(),
scopes: vec![TaintScope::Session],
});
let mut payload = RoutePayload::with_result(json!({}), json!({ "ssn": "555-12-3456" }));
let mut bag = AttributeBag::new();
let r = evaluate_route(
&route,
&mut bag,
&mut payload,
&pdp_arc(),
&plugins(),
&delegations(),
&elicitations(),
)
.await;
assert!(matches!(r.decision, Decision::Deny { .. }));
assert!(!r.result_modified, "post must be skipped on pre-side Deny");
assert!(r.taints.is_empty());
assert_eq!(
payload.result.as_ref().unwrap()["ssn"],
json!("555-12-3456")
);
}
}