use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicyEffect {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Condition {
Eq { attribute: String, value: serde_json::Value },
Ne { attribute: String, value: serde_json::Value },
In { attribute: String, values: Vec<serde_json::Value> },
Regex { attribute: String, pattern: String },
Gt { attribute: String, value: f64 },
Lt { attribute: String, value: f64 },
StartsWith { attribute: String, prefix: String },
EndsWith { attribute: String, suffix: String },
Time { attribute: String, start: String, end: String },
And { conditions: Vec<Condition> },
Or { conditions: Vec<Condition> },
Not { condition: Box<Condition> },
}
impl Condition {
pub fn evaluate(&self, attributes: &HashMap<String, serde_json::Value>) -> bool {
match self {
Condition::Eq { attribute, value } => {
attributes.get(attribute).map(|v| v == value).unwrap_or(false)
}
Condition::Ne { attribute, value } => {
attributes.get(attribute).map(|v| v != value).unwrap_or(true)
}
Condition::In { attribute, values } => {
attributes.get(attribute).map(|v| values.contains(v)).unwrap_or(false)
}
Condition::Regex { attribute, pattern } => {
if let Some(serde_json::Value::String(s)) = attributes.get(attribute) {
regex::Regex::new(pattern).map(|r| r.is_match(s)).unwrap_or(false)
} else {
false
}
}
Condition::Gt { attribute, value } => {
if let Some(serde_json::Value::Number(n)) = attributes.get(attribute) {
n.as_f64().map(|v| v > *value).unwrap_or(false)
} else {
false
}
}
Condition::Lt { attribute, value } => {
if let Some(serde_json::Value::Number(n)) = attributes.get(attribute) {
n.as_f64().map(|v| v < *value).unwrap_or(false)
} else {
false
}
}
Condition::StartsWith { attribute, prefix } => {
if let Some(serde_json::Value::String(s)) = attributes.get(attribute) {
s.starts_with(prefix)
} else {
false
}
}
Condition::EndsWith { attribute, suffix } => {
if let Some(serde_json::Value::String(s)) = attributes.get(attribute) {
s.ends_with(suffix)
} else {
false
}
}
Condition::Time { start, end, .. } => {
let parse = |s: &str| s.parse::<DateTime<Utc>>().ok();
match (parse(start), parse(end)) {
(Some(s), Some(e)) => {
let now = Utc::now();
now >= s && now <= e
}
_ => false,
}
}
Condition::And { conditions } => conditions.iter().all(|c| c.evaluate(attributes)),
Condition::Or { conditions } => conditions.iter().any(|c| c.evaluate(attributes)),
Condition::Not { condition } => !condition.evaluate(attributes),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub name: String,
pub description: Option<String>,
pub resource: String,
pub actions: Vec<String>,
pub effect: PolicyEffect,
pub conditions: Vec<Condition>,
pub priority: i32,
}
impl Policy {
pub fn new(name: impl Into<String>, resource: impl Into<String>, effect: PolicyEffect) -> Self {
Self {
name: name.into(),
description: None,
resource: resource.into(),
actions: Vec::new(),
effect,
conditions: Vec::new(),
priority: 0,
}
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.actions.push(action.into());
self
}
pub fn with_condition(mut self, condition: Condition) -> Self {
self.conditions.push(condition);
self
}
pub fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn applies_to(&self, resource: &str, action: &str) -> bool {
let resource_matches = if self.resource == "*" {
true
} else if self.resource.ends_with(":*") {
let prefix = &self.resource[..self.resource.len() - 2];
resource.starts_with(prefix)
} else {
self.resource == resource
};
let action_matches = self.actions.iter().any(|a| a == "*" || a == action);
resource_matches && action_matches
}
pub fn evaluate(
&self,
attributes: &HashMap<String, serde_json::Value>,
) -> Option<PolicyEffect> {
if self.conditions.is_empty() {
return Some(self.effect);
}
if self.conditions.iter().all(|c| c.evaluate(attributes)) {
Some(self.effect)
} else {
None
}
}
}
pub struct PolicyEngine {
policies: Vec<Policy>,
}
impl PolicyEngine {
pub fn new() -> Self {
Self { policies: Vec::new() }
}
pub fn add_policy(&mut self, policy: Policy) {
self.policies.push(policy);
self.policies.sort_by_key(|policy| std::cmp::Reverse(policy.priority));
}
pub fn add_policies(&mut self, policies: Vec<Policy>) {
for policy in policies {
self.add_policy(policy);
}
}
pub fn clear(&mut self) {
self.policies.clear();
}
pub fn evaluate(
&self,
resource: &str,
action: &str,
attributes: &HashMap<String, serde_json::Value>,
) -> bool {
let mut has_deny = false;
for policy in &self.policies {
if policy.applies_to(resource, action) {
if let Some(effect) = policy.evaluate(attributes) {
match effect {
PolicyEffect::Deny => {
has_deny = true;
break;
}
PolicyEffect::Allow => {
}
}
}
}
}
!has_deny
}
pub fn explain(
&self,
resource: &str,
action: &str,
attributes: &HashMap<String, serde_json::Value>,
) -> Vec<String> {
let mut reasons = Vec::new();
for policy in &self.policies {
if policy.applies_to(resource, action) {
if let Some(effect) = policy.evaluate(attributes) {
let effect_str = match effect {
PolicyEffect::Allow => "ALLOW",
PolicyEffect::Deny => "DENY",
};
reasons.push(format!(
"{}: {} (priority: {})",
effect_str, policy.name, policy.priority
));
}
}
}
reasons
}
}
impl Default for PolicyEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_policy() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::new("read-all", "documents:*", PolicyEffect::Allow)
.with_action("read")
.with_priority(1),
);
let attrs = HashMap::new();
assert!(engine.evaluate("documents:123", "read", &attrs));
assert!(engine.evaluate("documents:123", "write", &attrs));
}
#[test]
fn test_deny_blocks_allow() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::new("read-all", "documents:*", PolicyEffect::Allow)
.with_action("read")
.with_priority(1),
);
engine.add_policy(
Policy::new("deny-confidential", "documents:*", PolicyEffect::Deny)
.with_action("read")
.with_condition(Condition::Eq {
attribute: "classification".to_string(),
value: serde_json::json!("confidential"),
}),
);
let mut attrs = HashMap::new();
attrs.insert("classification".to_string(), serde_json::json!("public"));
assert!(engine.evaluate("documents:123", "read", &attrs));
attrs.insert("classification".to_string(), serde_json::json!("confidential"));
assert!(!engine.evaluate("documents:123", "read", &attrs));
}
#[test]
fn test_condition_time_malformed_bounds_deny() {
let condition = Condition::Time {
attribute: "ts".to_string(),
start: "00:00".to_string(), end: "01:00".to_string(), };
let attrs = HashMap::new();
assert!(!condition.evaluate(&attrs), "Malformed time bounds must deny (fail-closed)");
}
#[test]
fn test_condition_time_rfc3339_window_allows_current_time() {
let start = (chrono::Utc::now() - chrono::Duration::days(365)).to_rfc3339();
let end = (chrono::Utc::now() + chrono::Duration::days(365)).to_rfc3339();
let condition = Condition::Time { attribute: "ts".to_string(), start, end };
let attrs = HashMap::new();
assert!(condition.evaluate(&attrs), "Current time should fall within a ±1-year window");
}
#[test]
fn test_condition_time_rfc3339_expired_window_denies() {
let start = "2000-01-01T00:00:00Z".to_string();
let end = "2000-01-02T00:00:00Z".to_string();
let condition = Condition::Time { attribute: "ts".to_string(), start, end };
let attrs = HashMap::new();
assert!(!condition.evaluate(&attrs), "Past-only window must be denied");
}
#[test]
fn test_condition_and_or_not_logic() {
let attrs = HashMap::from([
("region".to_string(), serde_json::json!("us")),
("tier".to_string(), serde_json::json!("gold")),
("active".to_string(), serde_json::json!(true)),
]);
let and = Condition::And {
conditions: vec![
Condition::Eq { attribute: "region".to_string(), value: serde_json::json!("us") },
Condition::Not {
condition: Box::new(Condition::Eq {
attribute: "tier".to_string(),
value: serde_json::json!("bronze"),
}),
},
],
};
let or = Condition::Or {
conditions: vec![
Condition::Eq { attribute: "region".to_string(), value: serde_json::json!("eu") },
Condition::Eq { attribute: "active".to_string(), value: serde_json::json!(true) },
],
};
assert!(and.evaluate(&attrs));
assert!(or.evaluate(&attrs));
}
#[test]
fn test_policy_engine_add_clear_and_add_policies() {
let mut engine = PolicyEngine::new();
engine.add_policies(vec![
Policy::new("allow", "resource:*", PolicyEffect::Allow).with_action("read"),
Policy::new("deny", "resource:*", PolicyEffect::Deny).with_action("write"),
]);
assert_eq!(engine.explain("resource:1", "read", &HashMap::new()).len(), 1);
assert!(engine.evaluate("resource:1", "read", &HashMap::new()));
engine.clear();
assert!(engine.evaluate("resource:1", "read", &HashMap::new()));
assert_eq!(engine.explain("resource:1", "read", &HashMap::new()).len(), 0);
}
#[test]
fn test_policy_engine_default_allow_when_only_allow_policies() {
let mut engine = PolicyEngine::new();
let mut attrs = HashMap::new();
attrs.insert("classification".to_string(), serde_json::json!("public"));
engine.add_policy(
Policy::new("allow", "resource:*", PolicyEffect::Allow).with_action("read"),
);
assert!(engine.evaluate("resource:42", "read", &attrs));
assert_eq!(engine.explain("resource:42", "read", &attrs).len(), 1);
}
}