use std::sync::Arc;
use crate::attributes::{AttributeBag, AttributeValue};
use crate::pipeline::{Pipeline, ScanKind, Stage, TaintEvent, TaintScope, TypeCheck};
use crate::rules::{CompareOp, Condition, Effect, Expression, Literal, Rule};
use crate::step::{PdpResolver, PluginInvocation, PluginInvoker};
#[derive(Debug, Clone, PartialEq)]
pub enum Decision {
Allow,
Deny {
reason: Option<String>,
rule_source: String,
},
}
pub fn evaluate_rules(rules: &[Rule], bag: &AttributeBag) -> Decision {
for rule in rules {
if !eval_expression(&rule.condition, bag) {
continue;
}
for effect in &rule.effects {
match effect {
Effect::Allow => continue,
Effect::Deny { reason, code } => {
let rule_source = code.clone().unwrap_or_else(|| rule.source.clone());
return Decision::Deny {
reason: reason.clone(),
rule_source,
};
},
_ => continue,
}
}
}
Decision::Allow
}
fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool {
match expr {
Expression::Condition(c) => eval_condition(c, bag),
Expression::And(parts) => parts.iter().all(|e| eval_expression(e, bag)),
Expression::Or(parts) => parts.iter().any(|e| eval_expression(e, bag)),
Expression::Not(inner) => !eval_expression(inner, bag),
Expression::Always => true,
}
}
fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool {
match cond {
Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false),
Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false),
Condition::Exists { key } => bag.contains(key),
Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag),
Condition::InSet {
value_key,
set_key,
negate,
} => {
let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) {
(Some(s), Some(set)) => set.contains(s),
_ => false, };
if *negate {
!in_set
} else {
in_set
}
},
}
}
fn eval_comparison(key: &str, op: CompareOp, lit: &Literal, bag: &AttributeBag) -> bool {
let attr = match bag.get(key) {
Some(v) => v,
None => return false, };
match op {
CompareOp::Contains => match (attr, lit) {
(AttributeValue::StringSet(_), Literal::String(s)) => bag.set_contains(key, s),
_ => false,
},
CompareOp::Eq => values_eq(attr, lit),
CompareOp::NotEq => !values_eq(attr, lit),
CompareOp::Gt | CompareOp::GtEq | CompareOp::Lt | CompareOp::LtEq => {
numeric_compare(attr, lit, op)
},
}
}
fn values_eq(attr: &AttributeValue, lit: &Literal) -> bool {
match (attr, lit) {
(AttributeValue::Bool(a), Literal::Bool(b)) => a == b,
(AttributeValue::Int(a), Literal::Int(b)) => a == b,
(AttributeValue::Float(a), Literal::Float(b)) => a == b,
(AttributeValue::String(a), Literal::String(b)) => a == b,
(AttributeValue::Int(a), Literal::Float(b)) => (*a as f64) == *b,
(AttributeValue::Float(a), Literal::Int(b)) => *a == (*b as f64),
_ => false,
}
}
fn numeric_compare(attr: &AttributeValue, lit: &Literal, op: CompareOp) -> bool {
let (a, b) = match (attr, lit) {
(AttributeValue::Int(a), Literal::Int(b)) => (*a as f64, *b as f64),
(AttributeValue::Int(a), Literal::Float(b)) => (*a as f64, *b),
(AttributeValue::Float(a), Literal::Int(b)) => (*a, *b as f64),
(AttributeValue::Float(a), Literal::Float(b)) => (*a, *b),
_ => return false,
};
match op {
CompareOp::Gt => a > b,
CompareOp::GtEq => a >= b,
CompareOp::Lt => a < b,
CompareOp::LtEq => a <= b,
_ => unreachable!("numeric_compare called with non-numeric op"),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn evaluate_effects(
effects: &[Effect],
bag: &mut AttributeBag,
pdp: &Arc<dyn PdpResolver>,
plugins: &Arc<dyn PluginInvoker>,
delegations: &Arc<dyn crate::step::DelegationInvoker>,
phase: crate::step::DispatchPhase,
payload: &mut crate::route::RoutePayload,
) -> StepsEvaluation {
let mut taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
let mut args_modified = false;
let mut result_modified = false;
for effect in effects {
let fallback_source = match effect {
Effect::When { source, .. } => source.as_str(),
_ => "",
};
match Box::pin(dispatch_effect(
effect,
fallback_source,
bag,
pdp,
plugins,
delegations,
phase,
&mut taints,
&mut args_modified,
&mut result_modified,
payload,
))
.await
{
EffectOutcome::Continue => {},
EffectOutcome::Halt(decision) => {
return StepsEvaluation::deny(decision, taints, args_modified, result_modified);
},
}
}
StepsEvaluation {
decision: Decision::Allow,
taints,
args_modified,
result_modified,
}
}
#[derive(Debug, Clone)]
pub struct StepsEvaluation {
pub decision: Decision,
pub taints: Vec<crate::pipeline::TaintEvent>,
pub args_modified: bool,
pub result_modified: bool,
}
impl StepsEvaluation {
fn deny(
d: Decision,
taints: Vec<crate::pipeline::TaintEvent>,
args_modified: bool,
result_modified: bool,
) -> Self {
Self {
decision: d,
taints,
args_modified,
result_modified,
}
}
}
enum EffectOutcome {
Continue,
Halt(Decision),
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_effect(
effect: &Effect,
fallback_source: &str,
bag: &mut AttributeBag,
pdp: &Arc<dyn PdpResolver>,
plugins: &Arc<dyn PluginInvoker>,
delegations: &Arc<dyn crate::step::DelegationInvoker>,
phase: crate::step::DispatchPhase,
taints: &mut Vec<crate::pipeline::TaintEvent>,
args_modified: &mut bool,
result_modified: &mut bool,
payload: &mut crate::route::RoutePayload,
) -> EffectOutcome {
match effect {
Effect::Allow => EffectOutcome::Continue,
Effect::Deny { reason, code } => {
let rule_source = code.clone().unwrap_or_else(|| fallback_source.to_string());
EffectOutcome::Halt(Decision::Deny {
reason: reason.clone(),
rule_source,
})
},
Effect::Plugin { name } => {
match plugins
.invoke(name, bag, PluginInvocation::Step { phase })
.await
{
Ok(outcome) => {
taints.extend(outcome.taints);
match outcome.decision {
Decision::Allow => EffectOutcome::Continue,
deny @ Decision::Deny { .. } => EffectOutcome::Halt(deny),
}
},
Err(e) => EffectOutcome::Halt(Decision::Deny {
reason: Some(format!("plugin `{}` error: {}", name, e)),
rule_source: format!("plugin:{}", name),
}),
}
},
Effect::Delegate(delegate_step) => {
match delegations.delegate(delegate_step).await {
Ok(outcome) => match &outcome.decision {
Decision::Allow => {
use crate::attributes::AttributeValue;
use crate::step::delegation_bag_keys as bk;
bag.set(bk::GRANTED, AttributeValue::Bool(true));
if !outcome.granted_permissions.is_empty() {
let set: std::collections::HashSet<String> =
outcome.granted_permissions.iter().cloned().collect();
bag.set(bk::GRANTED_PERMISSIONS, AttributeValue::StringSet(set));
}
if let Some(aud) = &outcome.granted_audience {
bag.set(bk::GRANTED_AUDIENCE, aud.clone());
}
if let Some(exp) = &outcome.granted_expires_at {
bag.set(bk::GRANTED_EXPIRES_AT, exp.clone());
}
EffectOutcome::Continue
},
Decision::Deny { .. } => {
let on_error = delegate_step
.on_error
.as_deref()
.unwrap_or("deny")
.to_ascii_lowercase();
if on_error == "continue" {
EffectOutcome::Continue
} else {
EffectOutcome::Halt(outcome.decision)
}
},
},
Err(e) => {
let on_error = delegate_step
.on_error
.as_deref()
.unwrap_or("deny")
.to_ascii_lowercase();
if on_error == "continue" {
EffectOutcome::Continue
} else {
EffectOutcome::Halt(Decision::Deny {
reason: Some(format!(
"delegate `{}` error: {}",
delegate_step.plugin_name, e
)),
rule_source: delegate_step.source.clone(),
})
}
},
}
},
Effect::Taint { label, scopes } => {
taints.push(crate::pipeline::TaintEvent {
label: label.clone(),
scopes: scopes.clone(),
});
EffectOutcome::Continue
},
Effect::FieldOp { path, stages } => {
dispatch_field_op(
path,
stages,
fallback_source,
bag,
plugins,
phase,
taints,
args_modified,
result_modified,
payload,
)
.await
},
Effect::Sequential(effects) => {
for inner in effects {
match Box::pin(dispatch_effect(
inner,
fallback_source,
bag,
pdp,
plugins,
delegations,
phase,
taints,
args_modified,
result_modified,
payload,
))
.await
{
EffectOutcome::Continue => continue,
halt @ EffectOutcome::Halt(_) => return halt,
}
}
EffectOutcome::Continue
},
Effect::Parallel(effects) => {
dispatch_parallel(
effects,
fallback_source,
bag,
pdp,
plugins,
delegations,
phase,
taints,
payload,
)
.await
},
Effect::When {
condition,
body,
source,
} => {
if !eval_expression(condition, bag) {
return EffectOutcome::Continue;
}
for inner in body {
match Box::pin(dispatch_effect(
inner,
source,
bag,
pdp,
plugins,
delegations,
phase,
taints,
args_modified,
result_modified,
payload,
))
.await
{
EffectOutcome::Continue => continue,
halt @ EffectOutcome::Halt(_) => return halt,
}
}
EffectOutcome::Continue
},
Effect::Pdp {
call,
on_allow,
on_deny,
} => {
match pdp.evaluate(call, bag).await {
Ok(pdp_result) => match pdp_result.decision {
Decision::Allow => {
for inner in on_allow {
match Box::pin(dispatch_effect(
inner,
fallback_source,
bag,
pdp,
plugins,
delegations,
phase,
taints,
args_modified,
result_modified,
payload,
))
.await
{
EffectOutcome::Continue => continue,
halt @ EffectOutcome::Halt(_) => return halt,
}
}
EffectOutcome::Continue
},
deny @ Decision::Deny { .. } => {
for inner in on_deny {
if let EffectOutcome::Halt(reaction_decision) =
Box::pin(dispatch_effect(
inner,
fallback_source,
bag,
pdp,
plugins,
delegations,
phase,
taints,
args_modified,
result_modified,
payload,
))
.await
{
return EffectOutcome::Halt(reaction_decision);
}
}
EffectOutcome::Halt(deny)
},
},
Err(e) => EffectOutcome::Halt(Decision::Deny {
reason: Some(format!("PDP error: {}", e)),
rule_source: format!("pdp:{:?}", call.dialect),
}),
}
},
}
}
fn dispatch_parallel<'a>(
effects: &'a [Effect],
fallback_source: &'a str,
bag: &'a AttributeBag,
pdp: &'a Arc<dyn PdpResolver>,
plugins: &'a Arc<dyn PluginInvoker>,
delegations: &'a Arc<dyn crate::step::DelegationInvoker>,
phase: crate::step::DispatchPhase,
taints: &'a mut Vec<crate::pipeline::TaintEvent>,
payload: &'a crate::route::RoutePayload,
) -> futures::future::BoxFuture<'a, EffectOutcome> {
Box::pin(async move {
use cpex_orchestration::{run_branches, BranchConfig, BranchOutcome, ErasedBranch};
if effects.is_empty() {
return EffectOutcome::Continue;
}
let mut branches: Vec<ErasedBranch<(EffectOutcome, Vec<crate::pipeline::TaintEvent>)>> =
Vec::with_capacity(effects.len());
for effect in effects.iter() {
let effect = effect.clone();
let fallback = fallback_source.to_string();
let mut branch_bag = bag.clone();
let mut branch_payload = payload.clone();
let pdp = Arc::clone(pdp);
let plugins = Arc::clone(plugins);
let delegations = Arc::clone(delegations);
branches.push(Box::pin(async move {
let mut branch_taints: Vec<crate::pipeline::TaintEvent> = Vec::new();
let mut branch_args_modified = false;
let mut branch_result_modified = false;
let outcome = Box::pin(dispatch_effect(
&effect,
&fallback,
&mut branch_bag,
&pdp,
&plugins,
&delegations,
phase,
&mut branch_taints,
&mut branch_args_modified,
&mut branch_result_modified,
&mut branch_payload,
))
.await;
(outcome, branch_taints)
}));
}
let cfg = BranchConfig {
timeout_per_branch: None,
short_circuit_on_deny: true,
};
let outcomes = run_branches(
branches,
cfg,
|v: &(EffectOutcome, Vec<crate::pipeline::TaintEvent>)| {
matches!(v.0, EffectOutcome::Halt(_))
},
)
.await;
let mut first_halt: Option<Decision> = None;
for (idx, outcome) in outcomes.into_iter().enumerate() {
match outcome {
BranchOutcome::Completed((effect_outcome, branch_taints)) => {
taints.extend(branch_taints);
if first_halt.is_none() {
if let EffectOutcome::Halt(d) = effect_outcome {
first_halt = Some(d);
}
}
},
BranchOutcome::Aborted => {
},
BranchOutcome::TimedOut => {
},
BranchOutcome::Panicked(msg) => {
let _ = (idx, msg);
},
}
}
match first_halt {
Some(d) => EffectOutcome::Halt(d),
None => EffectOutcome::Continue,
}
})
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_field_op(
path: &str,
stages: &[crate::pipeline::Stage],
fallback_source: &str,
bag: &mut AttributeBag,
plugins: &Arc<dyn PluginInvoker>,
phase: crate::step::DispatchPhase,
taints: &mut Vec<crate::pipeline::TaintEvent>,
args_modified: &mut bool,
result_modified: &mut bool,
payload: &mut crate::route::RoutePayload,
) -> EffectOutcome {
use crate::route::{get_dotted, remove_dotted, set_dotted};
use crate::step::DispatchPhase;
enum Side {
Args,
Result,
}
let (root, subpath, side) = if let Some(rest) = path.strip_prefix("args.") {
if !matches!(phase, DispatchPhase::Pre) {
return EffectOutcome::Continue;
}
(&mut payload.args, rest, Side::Args)
} else if let Some(rest) = path.strip_prefix("result.") {
if !matches!(phase, DispatchPhase::Post) {
return EffectOutcome::Continue;
}
let Some(result) = payload.result.as_mut() else {
return EffectOutcome::Continue;
};
(result, rest, Side::Result)
} else {
return EffectOutcome::Halt(Decision::Deny {
reason: Some(format!(
"FieldOp path `{}` must start with `args.` or `result.`",
path
)),
rule_source: fallback_source.to_string(),
});
};
let Some(current) = get_dotted(root, subpath).cloned() else {
return EffectOutcome::Continue; };
let pipeline = crate::pipeline::Pipeline {
stages: stages.to_vec(),
};
let eval = evaluate_pipeline(&pipeline, ¤t, bag, plugins, path, phase).await;
taints.extend(eval.taints);
let mark_modified = |side: Side, args: &mut bool, result: &mut bool| match side {
Side::Args => *args = true,
Side::Result => *result = true,
};
match eval.outcome {
FieldOutcome::Pass => EffectOutcome::Continue,
FieldOutcome::Replace(new_val) => {
if set_dotted(root, subpath, new_val) {
mark_modified(side, args_modified, result_modified);
}
EffectOutcome::Continue
},
FieldOutcome::Omit => {
if remove_dotted(root, subpath) {
mark_modified(side, args_modified, result_modified);
}
EffectOutcome::Continue
},
FieldOutcome::Deny {
reason,
stage_index: _,
} => EffectOutcome::Halt(Decision::Deny {
reason: Some(reason),
rule_source: fallback_source.to_string(),
}),
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum FieldOutcome {
Pass,
Replace(serde_json::Value),
Omit,
Deny { reason: String, stage_index: usize },
}
#[derive(Debug, Clone, PartialEq)]
pub struct PipelineEvaluation {
pub outcome: FieldOutcome,
pub taints: Vec<TaintEvent>,
}
pub async fn evaluate_pipeline(
pipeline: &Pipeline,
value: &serde_json::Value,
bag: &AttributeBag,
plugins: &Arc<dyn PluginInvoker>,
field_name: &str,
phase: crate::step::DispatchPhase,
) -> PipelineEvaluation {
let mut current = value.clone();
let mut replaced = false;
let mut taints: Vec<TaintEvent> = Vec::new();
for (idx, stage) in pipeline.stages.iter().enumerate() {
match stage {
Stage::Type(tc) => {
if !type_check(tc, ¤t) {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("expected {:?}, got {}", tc, value_kind(¤t)),
stage_index: idx,
},
taints,
};
}
},
Stage::Length { min, max } => {
let Some(s) = current.as_str() else {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!(
"len(...) requires string value, got {}",
value_kind(¤t)
),
stage_index: idx,
},
taints,
};
};
let len = s.chars().count();
if min.map_or(false, |m| len < m) || max.map_or(false, |m| len > m) {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("length {} outside [{:?}, {:?}]", len, min, max),
stage_index: idx,
},
taints,
};
}
},
Stage::Range { min, max } => {
let Some(n) = current.as_i64() else {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!(
"range requires integer value, got {}",
value_kind(¤t)
),
stage_index: idx,
},
taints,
};
};
if min.map_or(false, |m| n < m) || max.map_or(false, |m| n > m) {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("value {} outside [{:?}, {:?}]", n, min, max),
stage_index: idx,
},
taints,
};
}
},
Stage::Enum { values } => {
let Some(s) = current.as_str() else {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!(
"enum(...) requires string value, got {}",
value_kind(¤t)
),
stage_index: idx,
},
taints,
};
};
if !values.iter().any(|v| v == s) {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("value `{}` not in enum {:?}", s, values),
stage_index: idx,
},
taints,
};
}
},
Stage::Regex { pattern } => {
let re = match regex::Regex::new(pattern) {
Ok(r) => r,
Err(e) => {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("invalid regex `{}`: {}", pattern, e),
stage_index: idx,
},
taints,
};
},
};
let Some(s) = current.as_str() else {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!(
"regex requires string value, got {}",
value_kind(¤t)
),
stage_index: idx,
},
taints,
};
};
if !re.is_match(s) {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("value did not match regex `{}`", pattern),
stage_index: idx,
},
taints,
};
}
},
Stage::Validate { name } => {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!(
"`validate({})` is not implemented; use `regex(...)` \
or `plugin({})` instead",
name, name,
),
stage_index: idx,
},
taints,
};
},
Stage::Mask { keep_last } => {
let Some(s) = current.as_str() else {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!(
"mask(...) requires string value, got {}",
value_kind(¤t)
),
stage_index: idx,
},
taints,
};
};
let chars: Vec<char> = s.chars().collect();
let keep = (*keep_last).min(chars.len());
let mask_count = chars.len() - keep;
let masked: String = std::iter::repeat('*')
.take(mask_count)
.chain(chars.into_iter().skip(mask_count))
.collect();
current = serde_json::Value::String(masked);
replaced = true;
},
Stage::Redact { condition } => {
let should_redact = match condition {
None => true,
Some(expr) => eval_expression(expr, bag),
};
if should_redact {
current = serde_json::Value::String("[REDACTED]".into());
replaced = true;
}
},
Stage::Omit => {
return PipelineEvaluation {
outcome: FieldOutcome::Omit,
taints,
};
},
Stage::Hash => {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
value_for_hash(¤t).hash(&mut h);
current = serde_json::Value::String(format!("hash:{:016x}", h.finish()));
replaced = true;
},
Stage::Taint { label, scopes } => {
taints.push(TaintEvent {
label: label.clone(),
scopes: scopes.clone(),
});
},
Stage::Plugin { name } => {
let invocation = PluginInvocation::Field {
name: field_name,
value: ¤t,
phase,
};
match plugins.invoke(name, bag, invocation).await {
Ok(outcome) => {
taints.extend(outcome.taints);
match outcome.decision {
Decision::Allow => {
if let Some(new_value) = outcome.modified_value {
current = new_value;
replaced = true;
}
},
Decision::Deny {
reason,
rule_source: _,
} => {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: reason
.unwrap_or_else(|| format!("plugin `{}` denied", name)),
stage_index: idx,
},
taints,
};
},
}
},
Err(e) => {
return PipelineEvaluation {
outcome: FieldOutcome::Deny {
reason: format!("plugin `{}` error: {}", name, e),
stage_index: idx,
},
taints,
};
},
}
},
Stage::Scan { kind } => {
let (label, redact): (&str, bool) = match kind {
ScanKind::PiiDetect => ("PII", false),
ScanKind::PiiRedact => ("PII", true),
ScanKind::InjectionScan => ("injection", false),
};
taints.push(TaintEvent {
label: label.to_string(),
scopes: vec![TaintScope::Session],
});
if redact {
current = serde_json::Value::String("[REDACTED]".into());
replaced = true;
}
},
}
}
let outcome = if replaced {
FieldOutcome::Replace(current)
} else {
FieldOutcome::Pass
};
PipelineEvaluation { outcome, taints }
}
fn type_check(tc: &TypeCheck, v: &serde_json::Value) -> bool {
match tc {
TypeCheck::Str => v.is_string(),
TypeCheck::Int => v.is_i64(),
TypeCheck::Bool => v.is_boolean(),
TypeCheck::Float => v.is_f64() || v.is_i64(),
TypeCheck::Email => v
.as_str()
.map_or(false, |s| s.contains('@') && s.contains('.')),
TypeCheck::Url => v.as_str().map_or(false, |s| {
s.starts_with("http://") || s.starts_with("https://")
}),
TypeCheck::Uuid => v.as_str().map_or(false, is_uuid_shape),
}
}
fn is_uuid_shape(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() != 36 {
return false;
}
for (i, &b) in bytes.iter().enumerate() {
match i {
8 | 13 | 18 | 23 => {
if b != b'-' {
return false;
}
},
_ => {
if !b.is_ascii_hexdigit() {
return false;
}
},
}
}
true
}
fn value_kind(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(n) if n.is_i64() => "int",
serde_json::Value::Number(_) => "float",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
fn value_for_hash(v: &serde_json::Value) -> String {
serde_json::to_string(v).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::{Condition, Expression, Rule};
use crate::step::{DelegationInvoker, NoopDelegationInvoker};
use std::collections::HashSet;
use std::sync::Arc;
fn rule(condition: Expression, effect: Effect, source: &str) -> Rule {
Rule::single(condition, effect, source)
}
fn null_pipe_plugins() -> Arc<dyn PluginInvoker> {
Arc::new(NullPipelinePlugins)
}
fn null_plugins() -> Arc<dyn PluginInvoker> {
Arc::new(NullPlugins)
}
fn noop_delegations() -> Arc<dyn DelegationInvoker> {
Arc::new(NoopDelegationInvoker)
}
fn deny(reason: &str) -> Effect {
Effect::Deny {
reason: Some(reason.into()),
code: None,
}
}
fn cond(c: Condition) -> Expression {
Expression::Condition(c)
}
#[test]
fn empty_rules_allow() {
let mut bag = AttributeBag::new();
assert_eq!(evaluate_rules(&[], &bag), Decision::Allow);
}
#[test]
fn first_deny_halts() {
let mut bag = AttributeBag::new();
bag.set("a", true);
bag.set("b", true);
let rules = vec![
rule(
cond(Condition::IsTrue { key: "a".into() }),
deny("first"),
"r0",
),
rule(
cond(Condition::IsTrue { key: "b".into() }),
deny("second"),
"r1",
),
];
match evaluate_rules(&rules, &bag) {
Decision::Deny {
reason,
rule_source,
} => {
assert_eq!(reason.as_deref(), Some("first"));
assert_eq!(rule_source, "r0");
},
d => panic!("expected Deny, got {:?}", d),
}
}
#[test]
fn allow_does_not_short_circuit() {
let mut bag = AttributeBag::new();
bag.set("ok", true);
bag.set("bad", true);
let rules = vec![
rule(
cond(Condition::IsTrue { key: "ok".into() }),
Effect::Allow,
"r0_allow",
),
rule(
cond(Condition::IsTrue { key: "bad".into() }),
deny("later"),
"r1_deny",
),
];
match evaluate_rules(&rules, &bag) {
Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r1_deny"),
d => panic!("allow short-circuited; expected later deny, got {:?}", d),
}
}
#[test]
fn unmatched_rules_dont_fire() {
let mut bag = AttributeBag::new(); let rules = vec![rule(
cond(Condition::IsTrue {
key: "denied".into(),
}),
deny("shouldn't fire"),
"r0",
)];
assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
}
#[test]
fn missing_key_is_false() {
let mut bag = AttributeBag::new();
assert!(!eval_condition(
&Condition::IsTrue {
key: "missing".into()
},
&bag
));
assert!(eval_condition(
&Condition::IsFalse {
key: "missing".into()
},
&bag
));
assert!(!eval_condition(
&Condition::Comparison {
key: "missing".into(),
op: CompareOp::Eq,
value: 1_i64.into(),
},
&bag,
));
}
#[test]
fn and_or_not_combinators() {
let mut bag = AttributeBag::new();
bag.set("a", true);
bag.set("b", false);
let a = cond(Condition::IsTrue { key: "a".into() });
let b = cond(Condition::IsTrue { key: "b".into() });
assert!(eval_expression(
&Expression::And(vec![a.clone(), a.clone()]),
&bag
));
assert!(!eval_expression(
&Expression::And(vec![a.clone(), b.clone()]),
&bag
));
assert!(eval_expression(
&Expression::Or(vec![a.clone(), b.clone()]),
&bag
));
assert!(!eval_expression(
&Expression::Or(vec![b.clone(), b.clone()]),
&bag
));
assert!(eval_expression(&Expression::Not(Box::new(b)), &bag));
}
#[test]
fn int_comparisons() {
let mut bag = AttributeBag::new();
bag.set("delegation.depth", 3_i64);
let cmp = |op| Condition::Comparison {
key: "delegation.depth".into(),
op,
value: 2_i64.into(),
};
assert!(eval_condition(&cmp(CompareOp::Gt), &bag));
assert!(eval_condition(&cmp(CompareOp::GtEq), &bag));
assert!(!eval_condition(&cmp(CompareOp::Lt), &bag));
assert!(!eval_condition(&cmp(CompareOp::Eq), &bag));
assert!(eval_condition(&cmp(CompareOp::NotEq), &bag));
}
#[test]
fn int_to_float_promotion_in_comparison() {
let mut bag = AttributeBag::new();
bag.set("delegation.depth", 2_i64);
assert!(!eval_condition(
&Condition::Comparison {
key: "delegation.depth".into(),
op: CompareOp::Gt,
value: 2.5_f64.into(),
},
&bag,
));
assert!(eval_condition(
&Condition::Comparison {
key: "delegation.depth".into(),
op: CompareOp::Lt,
value: 2.5_f64.into(),
},
&bag,
));
}
#[test]
fn string_equality_no_ordering() {
let mut bag = AttributeBag::new();
bag.set("subject.id", "alice");
assert!(eval_condition(
&Condition::Comparison {
key: "subject.id".into(),
op: CompareOp::Eq,
value: "alice".into(),
},
&bag,
));
assert!(!eval_condition(
&Condition::Comparison {
key: "subject.id".into(),
op: CompareOp::Gt,
value: "alice".into(),
},
&bag,
));
}
#[test]
fn contains_set_membership() {
let mut bag = AttributeBag::new();
bag.set(
"session.labels",
HashSet::from(["PII".to_string(), "financial".to_string()]),
);
assert!(eval_condition(
&Condition::Comparison {
key: "session.labels".into(),
op: CompareOp::Contains,
value: "PII".into(),
},
&bag,
));
assert!(!eval_condition(
&Condition::Comparison {
key: "session.labels".into(),
op: CompareOp::Contains,
value: "PHI".into(),
},
&bag,
));
bag.set("subject.id", "alice");
assert!(!eval_condition(
&Condition::Comparison {
key: "subject.id".into(),
op: CompareOp::Contains,
value: "alice".into(),
},
&bag,
));
}
#[test]
fn hr_compensation_scenario() {
let mut bag = AttributeBag::new();
bag.set("authenticated", true);
bag.set("role.hr", true);
bag.set("perm.view_ssn", true);
bag.set("delegation.depth", 1_i64);
bag.set("include_ssn", true);
let rules = vec![
rule(
Expression::Not(Box::new(cond(Condition::IsTrue {
key: "authenticated".into(),
}))),
deny("not authenticated"),
"r0",
),
rule(
Expression::And(vec![
cond(Condition::IsFalse {
key: "role.hr".into(),
}),
cond(Condition::IsFalse {
key: "role.finance".into(),
}),
]),
deny("not in hr/finance"),
"r1",
),
rule(
Expression::And(vec![
cond(Condition::Comparison {
key: "delegation.depth".into(),
op: CompareOp::Gt,
value: 2_i64.into(),
}),
cond(Condition::IsTrue {
key: "include_ssn".into(),
}),
]),
deny("delegation too deep for SSN"),
"r2",
),
];
assert_eq!(evaluate_rules(&rules, &bag), Decision::Allow);
bag.set("delegation.depth", 3_i64);
match evaluate_rules(&rules, &bag) {
Decision::Deny { rule_source, .. } => assert_eq!(rule_source, "r2"),
d => panic!("expected r2 deny, got {:?}", d),
}
}
use crate::pipeline::{Stage, TypeCheck};
use serde_json::json;
fn make_pipeline(stages: Vec<Stage>) -> crate::pipeline::Pipeline {
crate::pipeline::Pipeline { stages }
}
async fn run_pipeline(
p: &crate::pipeline::Pipeline,
v: &serde_json::Value,
bag: &AttributeBag,
) -> FieldOutcome {
evaluate_pipeline(
p,
v,
bag,
&null_pipe_plugins(),
"test_field",
crate::step::DispatchPhase::Pre,
)
.await
.outcome
}
struct NullPipelinePlugins;
#[async_trait]
impl PluginInvoker for NullPipelinePlugins {
async fn invoke(
&self,
name: &str,
_bag: &AttributeBag,
_invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError> {
panic!(
"NullPipelinePlugins should not dispatch; got plugin({})",
name
);
}
}
#[tokio::test]
async fn pipeline_empty_is_pass() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![]);
assert_eq!(
run_pipeline(&p, &json!("anything"), &bag).await,
FieldOutcome::Pass
);
}
#[tokio::test]
async fn pipeline_type_check_passes_and_denies() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Type(TypeCheck::Str)]);
assert_eq!(
run_pipeline(&p, &json!("hello"), &bag).await,
FieldOutcome::Pass
);
match run_pipeline(&p, &json!(42), &bag).await {
FieldOutcome::Deny {
reason,
stage_index,
} => {
assert!(reason.contains("expected Str"));
assert_eq!(stage_index, 0);
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_mask_preserves_last_n() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
FieldOutcome::Replace(v) => assert_eq!(v, json!("*******6789")),
other => panic!("expected Replace, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_mask_handles_short_strings() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Mask { keep_last: 4 }]);
match run_pipeline(&p, &json!("ab"), &bag).await {
FieldOutcome::Replace(v) => assert_eq!(v, json!("ab")),
other => panic!("expected Replace, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_unconditional_redact() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Redact { condition: None }]);
match run_pipeline(&p, &json!("secret"), &bag).await {
FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
other => panic!("expected Replace, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_conditional_redact_fires_when_condition_true() {
let mut bag = AttributeBag::new();
let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
key: "perm.view_ssn".into(),
})));
let p = make_pipeline(vec![Stage::Redact {
condition: Some(cond),
}]);
match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
FieldOutcome::Replace(v) => assert_eq!(v, json!("[REDACTED]")),
other => panic!("expected Replace (redact fired), got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_conditional_redact_skips_when_condition_false() {
let mut bag = AttributeBag::new();
bag.set("perm.view_ssn", true);
let cond = Expression::Not(Box::new(Expression::Condition(Condition::IsTrue {
key: "perm.view_ssn".into(),
})));
let p = make_pipeline(vec![Stage::Redact {
condition: Some(cond),
}]);
assert_eq!(
run_pipeline(&p, &json!("123-45-6789"), &bag).await,
FieldOutcome::Pass,
);
}
#[tokio::test]
async fn pipeline_omit_short_circuits() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![
Stage::Omit,
Stage::Type(TypeCheck::Int),
]);
assert_eq!(
run_pipeline(&p, &json!("anything"), &bag).await,
FieldOutcome::Omit
);
}
#[tokio::test]
async fn pipeline_range_validator() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![
Stage::Type(TypeCheck::Int),
Stage::Range {
min: Some(0),
max: Some(1_000_000),
},
]);
assert_eq!(
run_pipeline(&p, &json!(500_000), &bag).await,
FieldOutcome::Pass
);
match run_pipeline(&p, &json!(2_000_000), &bag).await {
FieldOutcome::Deny {
reason,
stage_index,
} => {
assert!(reason.contains("outside"));
assert_eq!(stage_index, 1);
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_length_validator() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Length {
min: None,
max: Some(5),
}]);
assert_eq!(
run_pipeline(&p, &json!("hi"), &bag).await,
FieldOutcome::Pass
);
assert!(matches!(
run_pipeline(&p, &json!("too long"), &bag).await,
FieldOutcome::Deny { .. },
));
}
#[tokio::test]
async fn pipeline_enum_validator() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Enum {
values: vec!["low".into(), "medium".into(), "high".into()],
}]);
assert_eq!(
run_pipeline(&p, &json!("medium"), &bag).await,
FieldOutcome::Pass
);
assert!(matches!(
run_pipeline(&p, &json!("extreme"), &bag).await,
FieldOutcome::Deny { .. },
));
}
#[tokio::test]
async fn pipeline_uuid_validator() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Type(TypeCheck::Uuid)]);
assert_eq!(
run_pipeline(&p, &json!("550e8400-e29b-41d4-a716-446655440000"), &bag).await,
FieldOutcome::Pass,
);
assert!(matches!(
run_pipeline(&p, &json!("not-a-uuid"), &bag).await,
FieldOutcome::Deny { .. },
));
}
#[tokio::test]
async fn pipeline_hash_replaces_value() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Hash]);
match run_pipeline(&p, &json!("secret"), &bag).await {
FieldOutcome::Replace(v) => {
let s = v.as_str().unwrap();
assert!(s.starts_with("hash:"));
assert_eq!(s.len(), "hash:".len() + 16);
},
other => panic!("expected Replace, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_validate_named_denies_at_runtime() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![
Stage::Type(TypeCheck::Str),
Stage::Validate {
name: "ssn_format".into(),
},
Stage::Mask { keep_last: 4 },
]);
match run_pipeline(&p, &json!("123-45-6789"), &bag).await {
FieldOutcome::Deny {
reason,
stage_index,
} => {
assert_eq!(stage_index, 1, "validate stage is at index 1");
assert!(
reason.contains("not implemented"),
"deny reason should explain that validate is unimplemented: {reason}",
);
assert!(
reason.contains("regex") || reason.contains("plugin"),
"deny reason should point at alternatives: {reason}",
);
},
other => panic!("expected Deny on validate(...) stage, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_validator_short_circuits_before_transform() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![
Stage::Type(TypeCheck::Int), Stage::Mask { keep_last: 4 },
]);
match run_pipeline(&p, &json!("hello"), &bag).await {
FieldOutcome::Deny { stage_index, .. } => assert_eq!(stage_index, 0),
other => panic!("expected Deny at stage 0, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_regex_match_passes() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Regex {
pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
}]);
assert_eq!(
run_pipeline(&p, &json!("123-45-6789"), &bag).await,
FieldOutcome::Pass
);
}
#[tokio::test]
async fn pipeline_regex_no_match_denies() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Regex {
pattern: r"^\d{3}-\d{2}-\d{4}$".into(),
}]);
match run_pipeline(&p, &json!("not an ssn"), &bag).await {
FieldOutcome::Deny {
reason,
stage_index,
} => {
assert!(reason.contains("did not match"));
assert_eq!(stage_index, 0);
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_regex_invalid_pattern_denies() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Regex {
pattern: "(unclosed".into(),
}]);
match run_pipeline(&p, &json!("anything"), &bag).await {
FieldOutcome::Deny { reason, .. } => {
assert!(reason.contains("invalid regex"));
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_regex_non_string_denies() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Regex {
pattern: r"^\d+$".into(),
}]);
match run_pipeline(&p, &json!(42), &bag).await {
FieldOutcome::Deny { reason, .. } => {
assert!(reason.contains("requires string"));
},
other => panic!("expected Deny on non-string regex input, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_taint_records_event() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![
Stage::Type(TypeCheck::Str),
Stage::Taint {
label: "PII".into(),
scopes: vec![TaintScope::Session],
},
Stage::Mask { keep_last: 4 },
]);
let result = evaluate_pipeline(
&p,
&json!("123-45-6789"),
&bag,
&null_pipe_plugins(),
"test_field",
crate::step::DispatchPhase::Pre,
)
.await;
assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
assert_eq!(
result.taints,
vec![TaintEvent {
label: "PII".into(),
scopes: vec![TaintScope::Session],
}]
);
}
#[tokio::test]
async fn pipeline_scan_pii_detect_emits_taint() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Scan {
kind: ScanKind::PiiDetect,
}]);
let result = evaluate_pipeline(
&p,
&json!("some text"),
&bag,
&null_pipe_plugins(),
"test_field",
crate::step::DispatchPhase::Pre,
)
.await;
assert_eq!(result.outcome, FieldOutcome::Pass);
assert_eq!(
result.taints,
vec![TaintEvent {
label: "PII".into(),
scopes: vec![TaintScope::Session],
}]
);
}
#[tokio::test]
async fn pipeline_scan_pii_redact_replaces_and_taints() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Scan {
kind: ScanKind::PiiRedact,
}]);
let result = evaluate_pipeline(
&p,
&json!("123-45-6789"),
&bag,
&null_pipe_plugins(),
"test_field",
crate::step::DispatchPhase::Pre,
)
.await;
assert_eq!(result.outcome, FieldOutcome::Replace(json!("[REDACTED]")));
assert_eq!(result.taints.len(), 1);
assert_eq!(result.taints[0].label, "PII");
}
#[tokio::test]
async fn pipeline_scan_injection_emits_injection_taint() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![Stage::Scan {
kind: ScanKind::InjectionScan,
}]);
let result = evaluate_pipeline(
&p,
&json!("user input"),
&bag,
&null_pipe_plugins(),
"test_field",
crate::step::DispatchPhase::Pre,
)
.await;
assert_eq!(result.outcome, FieldOutcome::Pass);
assert_eq!(result.taints[0].label, "injection");
}
#[tokio::test]
async fn pipeline_deny_does_not_accumulate_later_taints() {
let mut bag = AttributeBag::new();
let p = make_pipeline(vec![
Stage::Taint {
label: "before".into(),
scopes: vec![TaintScope::Session],
},
Stage::Type(TypeCheck::Int), Stage::Taint {
label: "after".into(),
scopes: vec![TaintScope::Session],
},
]);
let result = evaluate_pipeline(
&p,
&json!("hello"),
&bag,
&null_pipe_plugins(),
"test_field",
crate::step::DispatchPhase::Pre,
)
.await;
assert!(matches!(result.outcome, FieldOutcome::Deny { .. }));
assert_eq!(
result.taints,
vec![TaintEvent {
label: "before".into(),
scopes: vec![TaintScope::Session],
}]
);
}
struct PipePlugin {
outcomes: std::collections::HashMap<String, PluginOutcome>,
}
#[async_trait]
impl PluginInvoker for PipePlugin {
async fn invoke(
&self,
name: &str,
_bag: &AttributeBag,
_invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError> {
self.outcomes
.get(name)
.cloned()
.ok_or_else(|| PluginError::NotFound(name.into()))
}
}
#[tokio::test]
async fn pipeline_plugin_allow_continues() {
let mut bag = AttributeBag::new();
let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
outcomes: std::collections::HashMap::from([(
"noop".to_string(),
PluginOutcome::allow(),
)]),
});
let p = make_pipeline(vec![
Stage::Type(TypeCheck::Str),
Stage::Plugin {
name: "noop".into(),
},
Stage::Mask { keep_last: 4 },
]);
let result = evaluate_pipeline(
&p,
&json!("123-45-6789"),
&bag,
&plugins,
"compensation",
crate::step::DispatchPhase::Pre,
)
.await;
assert_eq!(result.outcome, FieldOutcome::Replace(json!("*******6789")));
assert!(result.taints.is_empty());
}
#[tokio::test]
async fn pipeline_plugin_can_replace_value() {
let mut bag = AttributeBag::new();
let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
outcomes: std::collections::HashMap::from([(
"scrubber".to_string(),
PluginOutcome {
decision: Decision::Allow,
taints: vec![TaintEvent {
label: "PII".to_string(),
scopes: vec![TaintScope::Session],
}],
modified_value: Some(json!("***scrubbed***")),
},
)]),
});
let p = make_pipeline(vec![Stage::Plugin {
name: "scrubber".into(),
}]);
let result = evaluate_pipeline(
&p,
&json!("sensitive data"),
&bag,
&plugins,
"notes",
crate::step::DispatchPhase::Pre,
)
.await;
assert_eq!(
result.outcome,
FieldOutcome::Replace(json!("***scrubbed***"))
);
assert_eq!(
result.taints,
vec![TaintEvent {
label: "PII".into(),
scopes: vec![TaintScope::Session],
}]
);
}
#[tokio::test]
async fn pipeline_plugin_deny_halts() {
let mut bag = AttributeBag::new();
let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
outcomes: std::collections::HashMap::from([(
"guard".to_string(),
PluginOutcome {
decision: Decision::Deny {
reason: Some("policy violation".into()),
rule_source: "guard".into(),
},
taints: vec![],
modified_value: None,
},
)]),
});
let p = make_pipeline(vec![
Stage::Plugin {
name: "guard".into(),
},
Stage::Mask { keep_last: 4 },
]);
let result = evaluate_pipeline(
&p,
&json!("data"),
&bag,
&plugins,
"payload",
crate::step::DispatchPhase::Pre,
)
.await;
match result.outcome {
FieldOutcome::Deny {
reason,
stage_index,
} => {
assert_eq!(reason, "policy violation");
assert_eq!(stage_index, 0);
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_plugin_missing_fails_closed() {
let mut bag = AttributeBag::new();
let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(PipePlugin {
outcomes: Default::default(),
});
let p = make_pipeline(vec![Stage::Plugin {
name: "missing".into(),
}]);
let result = evaluate_pipeline(
&p,
&json!("data"),
&bag,
&plugins,
"payload",
crate::step::DispatchPhase::Pre,
)
.await;
match result.outcome {
FieldOutcome::Deny { reason, .. } => assert!(reason.contains("missing")),
other => panic!("expected Deny on missing plugin, got {:?}", other),
}
}
#[test]
fn exists_distinguishes_missing_from_falsy() {
let mut bag = AttributeBag::new();
bag.set("args.flag", false);
assert!(!eval_condition(
&Condition::IsTrue {
key: "args.flag".into()
},
&bag
));
assert!(eval_condition(
&Condition::Exists {
key: "args.flag".into()
},
&bag
));
assert!(!eval_condition(
&Condition::Exists {
key: "args.nonexistent".into()
},
&bag
));
}
#[test]
fn in_set_member_and_non_member() {
let mut bag = AttributeBag::new();
bag.set("subject.type", "user");
bag.set(
"allowed_types",
std::collections::HashSet::from(["user".to_string(), "service".to_string()]),
);
assert!(eval_condition(
&Condition::InSet {
value_key: "subject.type".into(),
set_key: "allowed_types".into(),
negate: false,
},
&bag
));
bag.set("subject.type", "agent");
assert!(!eval_condition(
&Condition::InSet {
value_key: "subject.type".into(),
set_key: "allowed_types".into(),
negate: false,
},
&bag
));
}
#[test]
fn in_set_negate() {
let mut bag = AttributeBag::new();
bag.set("subject.type", "agent");
bag.set(
"blocked_types",
std::collections::HashSet::from(["service".to_string()]),
);
assert!(eval_condition(
&Condition::InSet {
value_key: "subject.type".into(),
set_key: "blocked_types".into(),
negate: true,
},
&bag
));
}
#[test]
fn in_set_missing_keys_resolve_to_false() {
let mut bag = AttributeBag::new();
assert!(!eval_condition(
&Condition::InSet {
value_key: "x".into(),
set_key: "y".into(),
negate: false,
},
&bag
));
assert!(eval_condition(
&Condition::InSet {
value_key: "x".into(),
set_key: "y".into(),
negate: true,
},
&bag
));
}
#[test]
fn always_evaluates_true() {
let mut bag = AttributeBag::new();
assert!(eval_expression(&Expression::Always, &bag));
}
#[test]
fn always_rule_unconditional_deny() {
let mut bag = AttributeBag::new();
let r = Rule {
condition: Expression::Always,
effects: vec![Effect::Deny {
reason: Some("unconditional".into()),
code: None,
}],
source: "test".into(),
};
match evaluate_rules(&[r], &bag) {
Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("unconditional")),
d => panic!("expected Deny, got {:?}", d),
}
}
use crate::step::{
PdpCall, PdpDecision, PdpDialect, PdpError, PdpResolver, PluginError, PluginInvocation,
PluginInvoker, PluginOutcome,
};
use async_trait::async_trait;
struct FakePdp {
decision: Decision,
}
#[async_trait]
impl PdpResolver for FakePdp {
fn dialect(&self) -> PdpDialect {
PdpDialect::Cedar
}
async fn evaluate(
&self,
_call: &PdpCall,
_bag: &AttributeBag,
) -> Result<PdpDecision, PdpError> {
Ok(PdpDecision {
decision: self.decision.clone(),
diagnostics: vec![],
})
}
}
struct ErroringPdp;
#[async_trait]
impl PdpResolver for ErroringPdp {
fn dialect(&self) -> PdpDialect {
PdpDialect::Cedar
}
async fn evaluate(
&self,
_call: &PdpCall,
_bag: &AttributeBag,
) -> Result<PdpDecision, PdpError> {
Err(PdpError::Dispatch("simulated PDP outage".into()))
}
}
struct FakePlugin {
decisions: std::collections::HashMap<String, Decision>,
}
#[async_trait]
impl PluginInvoker for FakePlugin {
async fn invoke(
&self,
name: &str,
_bag: &AttributeBag,
_invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError> {
match self.decisions.get(name) {
Some(d) => Ok(PluginOutcome {
decision: d.clone(),
taints: vec![],
modified_value: None,
}),
None => Err(PluginError::NotFound(name.into())),
}
}
}
struct NullPlugins;
#[async_trait]
impl PluginInvoker for NullPlugins {
async fn invoke(
&self,
name: &str,
_bag: &AttributeBag,
_invocation: PluginInvocation<'_>,
) -> Result<PluginOutcome, PluginError> {
Err(PluginError::NotFound(name.into()))
}
}
fn pdp_step(decision_diagnostic_label: &str) -> Effect {
Effect::Pdp {
call: PdpCall {
dialect: PdpDialect::Cedar,
args: serde_yaml::Value::String(decision_diagnostic_label.into()),
},
on_deny: vec![],
on_allow: vec![],
}
}
#[tokio::test]
async fn steps_rule_only_path() {
let mut bag = AttributeBag::new();
let steps = vec![Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "test".into(),
}];
let r = evaluate_effects(
&steps,
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await;
assert_eq!(r.decision, Decision::Allow);
}
#[tokio::test]
async fn pdp_allow_continues() {
let mut bag = AttributeBag::new();
let steps = vec![pdp_step("dummy")];
let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
decision: Decision::Allow,
});
assert_eq!(
evaluate_effects(
&steps,
&mut bag,
&pdp,
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null)
)
.await
.decision,
Decision::Allow,
);
}
#[tokio::test]
async fn pdp_deny_returns_deny() {
let mut bag = AttributeBag::new();
let steps = vec![pdp_step("dummy")];
let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
decision: Decision::Deny {
reason: Some("forbidden".into()),
rule_source: "pdp".into(),
},
});
match evaluate_effects(
&steps,
&mut bag,
&pdp,
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("forbidden")),
d => panic!("expected Deny, got {:?}", d),
}
}
#[tokio::test]
async fn pdp_on_deny_reaction_can_override_reason() {
let mut bag = AttributeBag::new();
let steps = vec![Effect::Pdp {
call: PdpCall {
dialect: PdpDialect::Cedar,
args: serde_yaml::Value::Null,
},
on_deny: vec![Effect::When {
condition: Expression::Always,
body: vec![Effect::Deny {
reason: Some("reaction took over".into()),
code: None,
}],
source: "on_deny[0]".into(),
}],
on_allow: vec![],
}];
let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
decision: Decision::Deny {
reason: Some("pdp original".into()),
rule_source: "p".into(),
},
});
match evaluate_effects(
&steps,
&mut bag,
&pdp,
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny {
reason,
rule_source,
} => {
assert_eq!(reason.as_deref(), Some("reaction took over"));
assert_eq!(rule_source, "on_deny[0]");
},
d => panic!("expected Deny, got {:?}", d),
}
}
#[tokio::test]
async fn pdp_on_allow_can_deny() {
let mut bag = AttributeBag::new();
let steps = vec![Effect::Pdp {
call: PdpCall {
dialect: PdpDialect::Cedar,
args: serde_yaml::Value::Null,
},
on_deny: vec![],
on_allow: vec![Effect::When {
condition: Expression::Always,
body: vec![Effect::Deny {
reason: Some("reaction veto".into()),
code: None,
}],
source: "on_allow[0]".into(),
}],
}];
let pdp: Arc<dyn PdpResolver> = Arc::new(FakePdp {
decision: Decision::Allow,
});
match evaluate_effects(
&steps,
&mut bag,
&pdp,
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("reaction veto")),
d => panic!("expected Deny, got {:?}", d),
}
}
#[tokio::test]
async fn pdp_error_is_fail_closed() {
let mut bag = AttributeBag::new();
let steps = vec![pdp_step("dummy")];
match evaluate_effects(
&steps,
&mut bag,
&(Arc::new(ErroringPdp) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny { reason, .. } => {
assert!(reason.unwrap().contains("PDP error"));
},
d => panic!("expected Deny on PDP error, got {:?}", d),
}
}
#[tokio::test]
async fn plugin_allow_continues_deny_halts() {
let mut bag = AttributeBag::new();
let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
decisions: std::collections::HashMap::from([
("ok_plugin".to_string(), Decision::Allow),
(
"blocking_plugin".to_string(),
Decision::Deny {
reason: Some("rate limit hit".into()),
rule_source: "plugin".into(),
},
),
]),
});
let allow_only = vec![Effect::Plugin {
name: "ok_plugin".into(),
}];
assert_eq!(
evaluate_effects(
&allow_only,
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow
}) as Arc<dyn PdpResolver>),
&plugins,
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null)
)
.await
.decision,
Decision::Allow,
);
let with_deny = vec![
Effect::Plugin {
name: "ok_plugin".into(),
},
Effect::Plugin {
name: "blocking_plugin".into(),
},
];
match evaluate_effects(
&with_deny,
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&plugins,
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("rate limit hit")),
d => panic!("expected Deny from blocking_plugin, got {:?}", d),
}
}
#[tokio::test]
async fn plugin_error_is_fail_closed() {
let mut bag = AttributeBag::new();
let plugins: std::sync::Arc<dyn PluginInvoker> = std::sync::Arc::new(FakePlugin {
decisions: Default::default(),
});
let steps = vec![Effect::Plugin {
name: "missing".into(),
}];
match evaluate_effects(
&steps,
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&plugins,
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny {
reason,
rule_source,
} => {
assert!(reason.unwrap().contains("missing"));
assert!(rule_source.contains("missing"));
},
d => panic!("expected Deny, got {:?}", d),
}
}
#[tokio::test]
async fn taint_step_always_continues_and_accumulates() {
let mut bag = AttributeBag::new();
let steps = vec![
Effect::Taint {
label: "PII".into(),
scopes: vec![crate::pipeline::TaintScope::Session],
},
Effect::When {
condition: Expression::Always,
body: vec![Effect::Deny {
reason: Some("after taint".into()),
code: None,
}],
source: "p[1]".into(),
},
];
let eval = evaluate_effects(
&steps,
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await;
match eval.decision {
Decision::Deny { reason, .. } => assert_eq!(reason.as_deref(), Some("after taint")),
d => panic!("expected Deny from rule after Taint, got {:?}", d),
}
assert_eq!(eval.taints.len(), 1);
assert_eq!(eval.taints[0].label, "PII");
assert_eq!(
eval.taints[0].scopes,
vec![crate::pipeline::TaintScope::Session]
);
}
#[tokio::test]
async fn field_op_in_do_redacts_args_during_pre_phase() {
let mut bag = AttributeBag::new();
let stages = vec![Stage::Redact { condition: None }];
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::FieldOp {
path: "args.ssn".into(),
stages,
}],
source: "demo.policy[0]".into(),
};
let steps = vec![Effect::from(rule)];
let mut payload = crate::route::RoutePayload::new(json!({
"ssn": "123-45-6789",
"name": "Jane",
}));
let eval = evaluate_effects(
&steps,
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
assert_eq!(eval.decision, Decision::Allow);
assert!(eval.args_modified, "FieldOp should flag args_modified");
assert_eq!(
payload.args.get("ssn").and_then(|v| v.as_str()),
Some("[REDACTED]")
);
assert_eq!(
payload.args.get("name").and_then(|v| v.as_str()),
Some("Jane")
);
}
#[tokio::test]
async fn field_op_targeting_result_in_pre_phase_is_skipped() {
let mut bag = AttributeBag::new();
let stages = vec![Stage::Redact { condition: None }];
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::FieldOp {
path: "result.ssn".into(),
stages,
}],
source: "demo.policy[0]".into(),
};
let mut payload = crate::route::RoutePayload::new(json!({}));
let eval = evaluate_effects(
&vec![Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
assert_eq!(eval.decision, Decision::Allow);
assert!(!eval.args_modified);
assert!(!eval.result_modified);
}
#[tokio::test]
async fn field_op_with_invalid_path_denies() {
let mut bag = AttributeBag::new();
let stages = vec![Stage::Redact { condition: None }];
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::FieldOp {
path: "ssn".into(), stages,
}],
source: "demo.policy[0]".into(),
};
let mut payload = crate::route::RoutePayload::new(json!({"ssn": "x"}));
let eval = evaluate_effects(
&vec![Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
match eval.decision {
Decision::Deny {
reason,
rule_source,
} => {
assert!(reason.unwrap_or_default().contains("must start with"));
assert_eq!(rule_source, "demo.policy[0]");
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn sequential_runs_effects_in_order_until_deny() {
let mut bag = AttributeBag::new();
let mut payload = crate::route::RoutePayload::new(json!({}));
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::Sequential(vec![
Effect::Allow,
Effect::Deny {
reason: Some("blocked by sequential".into()),
code: Some("seq.test".into()),
},
Effect::Allow, ])],
source: "test.policy[0]".into(),
};
let eval = evaluate_effects(
&vec![Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
match eval.decision {
Decision::Deny {
reason,
rule_source,
} => {
assert_eq!(reason.as_deref(), Some("blocked by sequential"));
assert_eq!(rule_source, "seq.test");
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn parallel_allows_when_no_branch_denies() {
let mut bag = AttributeBag::new();
let mut payload = crate::route::RoutePayload::new(json!({}));
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::Parallel(vec![
Effect::Allow,
Effect::Taint {
label: "audit_branch".into(),
scopes: vec![crate::pipeline::TaintScope::Session],
},
])],
source: "test.policy[0]".into(),
};
let eval = evaluate_effects(
&vec![Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
assert_eq!(eval.decision, Decision::Allow);
assert_eq!(eval.taints.len(), 1);
assert_eq!(eval.taints[0].label, "audit_branch");
}
#[tokio::test]
async fn parallel_denies_when_any_branch_denies() {
let mut bag = AttributeBag::new();
let mut payload = crate::route::RoutePayload::new(json!({}));
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::Parallel(vec![
Effect::Allow,
Effect::Deny {
reason: Some("branch 1 denied".into()),
code: None,
},
])],
source: "test.policy[0]".into(),
};
let eval = evaluate_effects(
&vec![Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
match eval.decision {
Decision::Deny { reason, .. } => {
assert_eq!(reason.as_deref(), Some("branch 1 denied"));
},
other => panic!("expected Deny, got {:?}", other),
}
}
#[tokio::test]
async fn parallel_picks_first_index_halt_not_first_to_complete() {
let mut bag = AttributeBag::new();
let mut payload = crate::route::RoutePayload::new(json!({}));
let rule = Rule {
condition: Expression::Always,
effects: vec![Effect::Parallel(vec![
Effect::Deny {
reason: Some("idx-0".into()),
code: None,
},
Effect::Deny {
reason: Some("idx-1".into()),
code: None,
},
])],
source: "test.policy[0]".into(),
};
let eval = evaluate_effects(
&vec![Effect::from(rule)],
&mut bag,
&(Arc::new(FakePdp {
decision: Decision::Allow,
}) as Arc<dyn PdpResolver>),
&null_plugins(),
&noop_delegations(),
crate::step::DispatchPhase::Pre,
&mut payload,
)
.await;
match eval.decision {
Decision::Deny { reason, .. } => {
assert_eq!(reason.as_deref(), Some("idx-0"), "lower-index halt wins");
},
other => panic!("expected Deny, got {:?}", other),
}
}
}