use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompareOp {
Eq,
NotEq,
Gt,
GtEq,
Lt,
LtEq,
Contains,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Literal {
Bool(bool),
Int(i64),
Float(f64),
String(String),
}
impl From<bool> for Literal {
fn from(v: bool) -> Self {
Literal::Bool(v)
}
}
impl From<i64> for Literal {
fn from(v: i64) -> Self {
Literal::Int(v)
}
}
impl From<f64> for Literal {
fn from(v: f64) -> Self {
Literal::Float(v)
}
}
impl From<&str> for Literal {
fn from(v: &str) -> Self {
Literal::String(v.to_string())
}
}
impl From<String> for Literal {
fn from(v: String) -> Self {
Literal::String(v)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Condition {
Comparison {
key: String,
op: CompareOp,
value: Literal,
},
IsTrue {
key: String,
},
IsFalse {
key: String,
},
Exists {
key: String,
},
InSet {
value_key: String,
set_key: String,
negate: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Expression {
Condition(Condition),
And(Vec<Expression>),
Or(Vec<Expression>),
Not(Box<Expression>),
Always,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Effect {
Allow,
Deny {
reason: Option<String>,
code: Option<String>,
},
Plugin {
name: String,
},
Delegate(crate::step::DelegateStep),
Elicit(crate::step::ElicitStep),
Taint {
label: String,
scopes: Vec<crate::pipeline::TaintScope>,
},
FieldOp {
path: String,
stages: Vec<crate::pipeline::Stage>,
},
Sequential(Vec<Effect>),
Parallel(Vec<Effect>),
When {
condition: Expression,
body: Vec<Effect>,
source: String,
},
Pdp {
call: crate::step::PdpCall,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
on_allow: Vec<Effect>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
on_deny: Vec<Effect>,
},
}
impl Effect {
pub fn contains_mutation(&self) -> bool {
match self {
Effect::FieldOp { .. } | Effect::Delegate(_) | Effect::Elicit(_) => true,
Effect::Sequential(effects) | Effect::Parallel(effects) => {
effects.iter().any(Effect::contains_mutation)
},
Effect::When { body, .. } => body.iter().any(Effect::contains_mutation),
Effect::Pdp {
on_allow, on_deny, ..
} => {
on_allow.iter().any(Effect::contains_mutation)
|| on_deny.iter().any(Effect::contains_mutation)
},
Effect::Allow | Effect::Deny { .. } | Effect::Plugin { .. } | Effect::Taint { .. } => {
false
},
}
}
pub fn validate_parallel_purity(&self) -> Result<(), String> {
match self {
Effect::Parallel(effects) => {
for e in effects {
if e.contains_mutation() {
return Err(format!(
"`parallel:` contains a mutation effect ({:?}); \
use `sequential:` for ordered mutations",
e
));
}
e.validate_parallel_purity()?;
}
Ok(())
},
Effect::Sequential(effects) => {
for e in effects {
e.validate_parallel_purity()?;
}
Ok(())
},
Effect::When { body, .. } => {
for e in body {
e.validate_parallel_purity()?;
}
Ok(())
},
Effect::Pdp {
on_allow, on_deny, ..
} => {
for e in on_allow.iter().chain(on_deny.iter()) {
e.validate_parallel_purity()?;
}
Ok(())
},
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub condition: Expression,
pub effects: Vec<Effect>,
pub source: String,
}
impl Rule {
pub fn single(condition: Expression, effect: Effect, source: impl Into<String>) -> Self {
Self {
condition,
effects: vec![effect],
source: source.into(),
}
}
}
impl From<Rule> for Effect {
fn from(r: Rule) -> Effect {
Effect::When {
condition: r.condition,
body: r.effects,
source: r.source,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Args,
Policy,
Result,
PostPolicy,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PhaseSet(u8);
impl PhaseSet {
pub fn new() -> Self {
Self(0)
}
pub fn insert(&mut self, p: Phase) {
self.0 |= Self::bit(p);
}
pub fn contains(&self, p: Phase) -> bool {
self.0 & Self::bit(p) != 0
}
pub fn is_empty(&self) -> bool {
self.0 == 0
}
fn bit(p: Phase) -> u8 {
match p {
Phase::Args => 0b0001,
Phase::Policy => 0b0010,
Phase::Result => 0b0100,
Phase::PostPolicy => 0b1000,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DenyResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub headers: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompiledRoute {
pub route_key: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<crate::pipeline::FieldRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub policy: Vec<Effect>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub result: Vec<crate::pipeline::FieldRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub post_policy: Vec<Effect>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub plugin_overrides: std::collections::HashMap<String, crate::plugin_decl::PluginOverride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<DenyResponse>,
}
impl CompiledRoute {
pub fn new(route_key: impl Into<String>) -> Self {
Self {
route_key: route_key.into(),
..Default::default()
}
}
pub fn declared_phases(&self) -> PhaseSet {
let mut set = PhaseSet::new();
if !self.args.is_empty() {
set.insert(Phase::Args);
}
if !self.policy.is_empty() {
set.insert(Phase::Policy);
}
if !self.result.is_empty() {
set.insert(Phase::Result);
}
if !self.post_policy.is_empty() {
set.insert(Phase::PostPolicy);
}
set
}
pub fn apply_layer(&mut self, more_specific: CompiledRoute) {
self.policy.extend(more_specific.policy);
self.post_policy.extend(more_specific.post_policy);
let ms_fields: std::collections::HashSet<String> =
more_specific.args.iter().map(|f| f.field.clone()).collect();
self.args.retain(|f| !ms_fields.contains(&f.field));
self.args.extend(more_specific.args);
let ms_result_fields: std::collections::HashSet<String> = more_specific
.result
.iter()
.map(|f| f.field.clone())
.collect();
self.result.retain(|f| !ms_result_fields.contains(&f.field));
self.result.extend(more_specific.result);
self.plugin_overrides.extend(more_specific.plugin_overrides);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_layer_does_not_propagate_response() {
let mut base = CompiledRoute::new("tool:x");
base.response = Some(DenyResponse {
status: Some(401),
..Default::default()
});
let mut layer = CompiledRoute::new("tool:x");
layer.response = Some(DenyResponse {
status: Some(403),
body: Some("forbidden".to_string()),
..Default::default()
});
base.apply_layer(layer);
assert_eq!(base.response.as_ref().unwrap().status, Some(401));
let mut empty = CompiledRoute::new("tool:x");
let mut with_resp = CompiledRoute::new("tool:x");
with_resp.response = Some(DenyResponse {
status: Some(418),
..Default::default()
});
empty.apply_layer(with_resp);
assert!(empty.response.is_none());
}
#[test]
fn phase_set_basic() {
let mut set = PhaseSet::new();
assert!(set.is_empty());
set.insert(Phase::Policy);
set.insert(Phase::Result);
assert!(set.contains(Phase::Policy));
assert!(set.contains(Phase::Result));
assert!(!set.contains(Phase::Args));
assert!(!set.contains(Phase::PostPolicy));
assert!(!set.is_empty());
}
#[test]
fn compiled_route_declared_phases() {
let mut route = CompiledRoute::new("get_compensation");
assert!(route.declared_phases().is_empty());
route.policy.push(Effect::When {
condition: Expression::Condition(Condition::IsTrue {
key: "authenticated".into(),
}),
body: vec![Effect::Allow],
source: "policy[0]".into(),
});
let phases = route.declared_phases();
assert!(phases.contains(Phase::Policy));
assert!(!phases.contains(Phase::Args));
}
#[test]
fn literal_from_impls() {
let r = Rule {
condition: Expression::Condition(Condition::Comparison {
key: "delegation.depth".into(),
op: CompareOp::Gt,
value: 2_i64.into(),
}),
effects: vec![Effect::Deny {
reason: Some("too deep".into()),
code: None,
}],
source: "policy[0]".into(),
};
if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
assert_eq!(value, Literal::Int(2));
} else {
panic!("expected Comparison");
}
}
#[test]
fn rule_serde_roundtrip() {
let r = Rule {
condition: Expression::And(vec![
Expression::Condition(Condition::IsTrue {
key: "authenticated".into(),
}),
Expression::Condition(Condition::Comparison {
key: "delegation.depth".into(),
op: CompareOp::LtEq,
value: 3_i64.into(),
}),
]),
effects: vec![Effect::Allow],
source: "policy[1]".into(),
};
let json = serde_json::to_string(&r).unwrap();
let back: Rule = serde_json::from_str(&json).unwrap();
assert!(matches!(back.effects.as_slice(), [Effect::Allow]));
assert_eq!(back.source, "policy[1]");
}
#[test]
fn compiled_route_serde_skips_empty_phases() {
let route = CompiledRoute::new("ping");
let json = serde_json::to_string(&route).unwrap();
assert_eq!(json, r#"{"route_key":"ping"}"#);
}
#[test]
fn apply_layer_appends_policy_and_post_policy_in_evaluation_order() {
let mut effective = CompiledRoute::new("route.get_compensation");
effective.policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "global.policy[0]".into(),
});
effective.post_policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "global.post_policy[0]".into(),
});
let mut route_layer = CompiledRoute::new("ignored");
route_layer.policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "route.policy[0]".into(),
});
route_layer.post_policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "route.post_policy[0]".into(),
});
effective.apply_layer(route_layer);
assert_eq!(effective.policy.len(), 2);
match &effective.policy[0] {
Effect::When { source, .. } => assert_eq!(source, "global.policy[0]"),
_ => panic!(),
}
match &effective.policy[1] {
Effect::When { source, .. } => assert_eq!(source, "route.policy[0]"),
_ => panic!(),
}
assert_eq!(effective.post_policy.len(), 2);
assert_eq!(effective.route_key, "route.get_compensation");
}
#[test]
fn apply_layer_args_more_specific_wins_on_field_collision() {
use crate::pipeline::{FieldRule, Pipeline, Stage, TypeCheck};
let mut effective = CompiledRoute::new("route.X");
effective.args.push(FieldRule {
field: "id".into(),
pipeline: Pipeline {
stages: vec![Stage::Type(TypeCheck::Str)],
},
source: "default.args.id".into(),
});
effective.args.push(FieldRule {
field: "trace_id".into(),
pipeline: Pipeline {
stages: vec![Stage::Type(TypeCheck::Str)],
},
source: "default.args.trace_id".into(),
});
let mut route_layer = CompiledRoute::new("ignored");
route_layer.args.push(FieldRule {
field: "id".into(),
pipeline: Pipeline {
stages: vec![Stage::Type(TypeCheck::Uuid)],
},
source: "route.args.id".into(),
});
effective.apply_layer(route_layer);
assert_eq!(effective.args.len(), 2);
let id_rule = effective.args.iter().find(|f| f.field == "id").unwrap();
assert!(matches!(
id_rule.pipeline.stages[0],
Stage::Type(TypeCheck::Uuid)
));
assert_eq!(id_rule.source, "route.args.id");
let trace = effective
.args
.iter()
.find(|f| f.field == "trace_id")
.unwrap();
assert_eq!(trace.source, "default.args.trace_id");
}
#[test]
fn apply_layer_plugin_overrides_more_specific_wins() {
use crate::plugin_decl::PluginOverride;
let mut effective = CompiledRoute::new("route.X");
effective.plugin_overrides.insert(
"rate_limiter".into(),
PluginOverride {
on_error: Some("ignore".into()),
..Default::default()
},
);
effective.plugin_overrides.insert(
"audit_logger".into(),
PluginOverride {
on_error: Some("ignore".into()),
..Default::default()
},
);
let mut route_layer = CompiledRoute::new("ignored");
route_layer.plugin_overrides.insert(
"rate_limiter".into(),
PluginOverride {
on_error: Some("fail".into()),
..Default::default()
},
);
effective.apply_layer(route_layer);
assert_eq!(effective.plugin_overrides.len(), 2);
assert_eq!(
effective.plugin_overrides["rate_limiter"]
.on_error
.as_deref(),
Some("fail"),
"route's override wins on collision",
);
assert_eq!(
effective.plugin_overrides["audit_logger"]
.on_error
.as_deref(),
Some("ignore"),
);
}
#[test]
fn apply_layer_chained_walks_hierarchy_in_specificity_order() {
let mut effective = CompiledRoute::new("route.get_compensation");
let mut global = CompiledRoute::default();
global.policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "global.policy[0]".into(),
});
let mut default = CompiledRoute::default();
default.policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "default.policy[0]".into(),
});
let mut tag = CompiledRoute::default();
tag.policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "tag.hr.policy[0]".into(),
});
let mut route = CompiledRoute::default();
route.policy.push(Effect::When {
condition: Expression::Always,
body: vec![Effect::Allow],
source: "route.policy[0]".into(),
});
effective.apply_layer(global);
effective.apply_layer(default);
effective.apply_layer(tag);
effective.apply_layer(route);
let sources: Vec<&str> = effective
.policy
.iter()
.map(|s| match s {
Effect::When { source, .. } => source.as_str(),
_ => "<not-rule>",
})
.collect();
assert_eq!(
sources,
vec![
"global.policy[0]",
"default.policy[0]",
"tag.hr.policy[0]",
"route.policy[0]",
]
);
}
#[test]
fn validate_parallel_pure_block_passes() {
let effect = Effect::Parallel(vec![
Effect::Plugin {
name: "rate_limiter".into(),
},
Effect::Plugin {
name: "audit".into(),
},
Effect::Allow,
]);
assert!(effect.validate_parallel_purity().is_ok());
}
#[test]
fn validate_parallel_rejects_field_op() {
let effect = Effect::Parallel(vec![
Effect::Plugin {
name: "audit".into(),
},
Effect::FieldOp {
path: "args.ssn".into(),
stages: vec![],
},
]);
let err = effect.validate_parallel_purity().unwrap_err();
assert!(err.contains("mutation"), "got: {}", err);
assert!(err.contains("FieldOp"), "should name the offender: {}", err);
}
#[test]
fn validate_parallel_rejects_delegate() {
let delegate = Effect::Delegate(crate::step::DelegateStep {
plugin_name: "workday".into(),
config_override: None,
on_error: None,
source: "test".into(),
});
let effect = Effect::Parallel(vec![Effect::Allow, delegate]);
let err = effect.validate_parallel_purity().unwrap_err();
assert!(err.contains("mutation"));
}
#[test]
fn validate_parallel_recurses_into_nested_parallel() {
let inner_parallel = Effect::Parallel(vec![Effect::FieldOp {
path: "args.x".into(),
stages: vec![],
}]);
let outer = Effect::Parallel(vec![Effect::Sequential(vec![
Effect::Allow,
inner_parallel,
])]);
assert!(outer.validate_parallel_purity().is_err());
}
#[test]
fn validate_top_level_sequential_allows_mutations() {
let effect = Effect::Sequential(vec![
Effect::FieldOp {
path: "args.ssn".into(),
stages: vec![],
},
Effect::Allow,
]);
assert!(effect.validate_parallel_purity().is_ok());
}
#[test]
fn validate_contains_mutation_classifies_each_variant() {
assert!(!Effect::Allow.contains_mutation());
assert!(!Effect::Deny {
reason: None,
code: None
}
.contains_mutation());
assert!(!Effect::Plugin { name: "x".into() }.contains_mutation());
assert!(!Effect::Taint {
label: "x".into(),
scopes: vec![],
}
.contains_mutation());
assert!(Effect::FieldOp {
path: "args.x".into(),
stages: vec![],
}
.contains_mutation());
assert!(Effect::Delegate(crate::step::DelegateStep {
plugin_name: "x".into(),
config_override: None,
on_error: None,
source: "x".into(),
})
.contains_mutation());
let pure_seq = Effect::Sequential(vec![Effect::Allow]);
assert!(!pure_seq.contains_mutation());
let dirty_seq = Effect::Sequential(vec![Effect::FieldOp {
path: "args.x".into(),
stages: vec![],
}]);
assert!(dirty_seq.contains_mutation());
}
}