use crate::types::{
CandidateDecision, ConflictResolutionStrategy, PolicyDecision, PolicyScope, ResolutionResult,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
pub name: String,
#[serde(rename = "type")]
pub rule_type: String,
#[serde(default)]
pub allowed_actions: Vec<String>,
#[serde(default)]
pub denied_actions: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
#[serde(default)]
pub min_approvals: u32,
#[serde(default)]
pub max_calls: u32,
#[serde(default)]
pub window: String,
#[serde(default)]
pub conditions: HashMap<String, serde_yaml::Value>,
#[serde(default)]
pub priority: u32,
#[serde(default)]
pub scope: PolicyScope,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyProfile {
pub version: String,
pub agent: String,
pub policies: Vec<PolicyRule>,
}
pub struct PolicyEngine {
profile: RwLock<Option<PolicyProfile>>,
rate_counters: Mutex<HashMap<String, (u64, Instant)>>,
conflict_strategy: ConflictResolutionStrategy,
}
impl PolicyEngine {
pub fn new() -> Self {
Self {
profile: RwLock::new(None),
rate_counters: Mutex::new(HashMap::new()),
conflict_strategy: ConflictResolutionStrategy::PriorityFirstMatch,
}
}
pub fn with_strategy(strategy: ConflictResolutionStrategy) -> Self {
Self {
profile: RwLock::new(None),
rate_counters: Mutex::new(HashMap::new()),
conflict_strategy: strategy,
}
}
pub fn strategy(&self) -> ConflictResolutionStrategy {
self.conflict_strategy
}
pub fn resolve_conflicts(&self, candidates: &[CandidateDecision]) -> ResolutionResult {
if candidates.is_empty() {
return ResolutionResult {
winning_decision: PolicyDecision::Allow,
strategy_used: self.conflict_strategy,
conflict_detected: false,
candidates_evaluated: 0,
};
}
if candidates.len() == 1 {
return ResolutionResult {
winning_decision: candidates[0].decision.clone(),
strategy_used: self.conflict_strategy,
conflict_detected: false,
candidates_evaluated: 1,
};
}
let has_allow = candidates.iter().any(|c| c.decision.is_allowed());
let has_deny = candidates
.iter()
.any(|c| matches!(c.decision, PolicyDecision::Deny(_)));
let conflict_detected = has_allow && has_deny;
let mut sorted = candidates.to_vec();
let winning = match self.conflict_strategy {
ConflictResolutionStrategy::DenyOverrides => {
sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
match sorted
.iter()
.find(|c| matches!(c.decision, PolicyDecision::Deny(_)))
{
Some(d) => d.clone(),
None => sorted[0].clone(),
}
}
ConflictResolutionStrategy::AllowOverrides => {
sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
match sorted.iter().find(|c| c.decision.is_allowed()) {
Some(a) => a.clone(),
None => sorted[0].clone(),
}
}
ConflictResolutionStrategy::PriorityFirstMatch => {
sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
sorted[0].clone()
}
ConflictResolutionStrategy::MostSpecificWins => {
sorted.sort_by(|a, b| {
b.scope
.specificity()
.cmp(&a.scope.specificity())
.then(b.priority.cmp(&a.priority))
});
sorted[0].clone()
}
};
ResolutionResult {
winning_decision: winning.decision,
strategy_used: self.conflict_strategy,
conflict_detected,
candidates_evaluated: candidates.len(),
}
}
pub fn is_loaded(&self) -> bool {
self.profile.read().expect("policy profile lock poisoned").is_some()
}
pub fn load_from_yaml(&self, yaml: &str) -> Result<(), PolicyError> {
let profile: PolicyProfile =
serde_yaml::from_str(yaml).map_err(PolicyError::InvalidYaml)?;
*self.profile.write().expect("policy profile lock poisoned") = Some(profile);
Ok(())
}
pub fn load_from_file(&self, path: &str) -> Result<(), PolicyError> {
let yaml = std::fs::read_to_string(path).map_err(PolicyError::Io)?;
self.load_from_yaml(&yaml)
}
pub fn evaluate(
&self,
action: &str,
context: Option<&HashMap<String, serde_yaml::Value>>,
) -> PolicyDecision {
let guard = self.profile.read().expect("policy profile lock poisoned");
let profile = match guard.as_ref() {
Some(p) => p,
None => return PolicyDecision::Allow,
};
for rule in &profile.policies {
if !conditions_match(&rule.conditions, context) {
continue;
}
match rule.rule_type.as_str() {
"capability" => {
for denied in &rule.denied_actions {
if action_matches(action, denied) {
return PolicyDecision::Deny(format!(
"Blocked by policy '{}': action '{}' is denied",
rule.name, action
));
}
}
if !rule.allowed_actions.is_empty() {
if rule.allowed_actions.iter().any(|a| action_matches(action, a)) {
return PolicyDecision::Allow;
}
let in_scope = rule.denied_actions.iter().any(|d| {
let ns = d.trim_end_matches('*').trim_end_matches(':');
action.starts_with(ns)
}) || rule.allowed_actions.iter().any(|a| {
let ns = a.split('.').next().unwrap_or("");
action.starts_with(ns)
});
if in_scope {
return PolicyDecision::Deny(format!(
"Blocked by policy '{}': action '{}' not in allowlist",
rule.name, action
));
}
}
}
"approval" => {
for pattern in &rule.actions {
if action_matches(action, pattern) {
return PolicyDecision::RequiresApproval(format!(
"Policy '{}' requires {} approval(s) for '{}'",
rule.name, rule.min_approvals, action
));
}
}
}
"rate_limit" => {
if rule.max_calls > 0 {
for pattern in &rule.actions {
if action_matches(action, pattern) {
return self.check_rate_limit(
&rule.name,
rule.max_calls,
&rule.window,
);
}
}
}
}
_ => {}
}
}
PolicyDecision::Allow
}
fn check_rate_limit(&self, name: &str, max_calls: u32, window: &str) -> PolicyDecision {
let window_secs = parse_duration(window);
let mut counters = self.rate_counters.lock().expect("rate counter lock poisoned");
let entry = counters
.entry(name.to_string())
.or_insert((0, Instant::now()));
if entry.1.elapsed().as_secs() > window_secs {
*entry = (1, Instant::now());
PolicyDecision::Allow
} else if entry.0 >= max_calls as u64 {
let retry_after = window_secs.saturating_sub(entry.1.elapsed().as_secs());
PolicyDecision::RateLimited {
retry_after_secs: retry_after,
}
} else {
entry.0 += 1;
PolicyDecision::Allow
}
}
}
impl Default for PolicyEngine {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("invalid YAML: {0}")]
InvalidYaml(serde_yaml::Error),
#[error("I/O error: {0}")]
Io(std::io::Error),
}
fn action_matches(action: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix(".*") {
return action.starts_with(&format!("{}.", prefix));
}
if let Some(prefix) = pattern.strip_suffix('*') {
return action.starts_with(prefix);
}
action == pattern
}
fn conditions_match(
conditions: &HashMap<String, serde_yaml::Value>,
context: Option<&HashMap<String, serde_yaml::Value>>,
) -> bool {
if conditions.is_empty() {
return true;
}
let ctx = match context {
Some(c) => c,
None => return false,
};
for (key, expected) in conditions {
match ctx.get(key) {
Some(actual) if actual == expected => {}
_ => return false,
}
}
true
}
fn parse_duration(s: &str) -> u64 {
if let Some(val) = s.strip_suffix('m') {
val.parse::<u64>().unwrap_or(1) * 60
} else if let Some(val) = s.strip_suffix('s') {
val.parse::<u64>().unwrap_or(60)
} else if let Some(val) = s.strip_suffix('h') {
val.parse::<u64>().unwrap_or(1) * 3600
} else {
s.parse::<u64>().unwrap_or(60)
}
}
#[cfg(test)]
mod tests {
use super::*;
const POLICY_YAML: &str = r#"
version: "1.0"
agent: test-agent
policies:
- name: capability-gate
type: capability
allowed_actions:
- "data.read"
- "data.write"
denied_actions:
- "shell:*"
- name: deploy-approval
type: approval
actions:
- "deploy.*"
min_approvals: 2
- name: api-rate-limit
type: rate_limit
actions:
- "api.call"
max_calls: 3
window: "60s"
"#;
#[test]
fn test_allow_listed_action() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
}
#[test]
fn test_deny_shell() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
let decision = engine.evaluate("shell:rm", None);
assert!(matches!(decision, PolicyDecision::Deny(_)));
}
#[test]
fn test_not_in_allowlist_in_scope() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
let decision = engine.evaluate("data.delete", None);
assert!(matches!(decision, PolicyDecision::Deny(_)));
}
#[test]
fn test_out_of_scope_falls_through() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
let decision = engine.evaluate("admin.delete", None);
assert_eq!(decision, PolicyDecision::Allow);
}
#[test]
fn test_approval_required() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
let decision = engine.evaluate("deploy.production", None);
assert!(matches!(decision, PolicyDecision::RequiresApproval(_)));
}
#[test]
fn test_rate_limiting() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
for _ in 0..3 {
assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
}
let decision = engine.evaluate("api.call", None);
assert!(matches!(decision, PolicyDecision::RateLimited { .. }));
}
#[test]
fn test_no_profile_allows_all() {
let engine = PolicyEngine::new();
assert_eq!(engine.evaluate("anything", None), PolicyDecision::Allow);
}
#[test]
fn test_action_matches() {
assert!(action_matches("shell:ls", "shell:*"));
assert!(action_matches("data.read", "data.*"));
assert!(action_matches("deploy.staging", "deploy.*"));
assert!(!action_matches("data.read", "shell:*"));
assert!(action_matches("anything", "*"));
assert!(action_matches("data.read", "data.read"));
assert!(!action_matches("data.write", "data.read"));
}
#[test]
fn test_with_strategy_constructor() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
assert_eq!(engine.strategy(), ConflictResolutionStrategy::DenyOverrides);
}
#[test]
fn test_default_strategy_is_priority_first_match() {
let engine = PolicyEngine::new();
assert_eq!(
engine.strategy(),
ConflictResolutionStrategy::PriorityFirstMatch
);
}
#[test]
fn test_resolve_conflicts_empty() {
let engine = PolicyEngine::new();
let result = engine.resolve_conflicts(&[]);
assert_eq!(result.winning_decision, PolicyDecision::Allow);
assert!(!result.conflict_detected);
assert_eq!(result.candidates_evaluated, 0);
}
#[test]
fn test_resolve_conflicts_single() {
let engine = PolicyEngine::new();
let candidates = vec![CandidateDecision {
decision: PolicyDecision::Deny("blocked".into()),
priority: 1,
scope: PolicyScope::Global,
rule_name: "rule-1".into(),
}];
let result = engine.resolve_conflicts(&candidates);
assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
assert!(!result.conflict_detected);
assert_eq!(result.candidates_evaluated, 1);
}
#[test]
fn test_resolve_conflicts_deny_overrides() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
let candidates = vec![
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 10,
scope: PolicyScope::Global,
rule_name: "allow-rule".into(),
},
CandidateDecision {
decision: PolicyDecision::Deny("no".into()),
priority: 5,
scope: PolicyScope::Global,
rule_name: "deny-rule".into(),
},
];
let result = engine.resolve_conflicts(&candidates);
assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
assert!(result.conflict_detected);
}
#[test]
fn test_resolve_conflicts_allow_overrides() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::AllowOverrides);
let candidates = vec![
CandidateDecision {
decision: PolicyDecision::Deny("blocked".into()),
priority: 10,
scope: PolicyScope::Global,
rule_name: "deny-rule".into(),
},
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 5,
scope: PolicyScope::Global,
rule_name: "allow-rule".into(),
},
];
let result = engine.resolve_conflicts(&candidates);
assert_eq!(result.winning_decision, PolicyDecision::Allow);
assert!(result.conflict_detected);
}
#[test]
fn test_resolve_conflicts_priority_first_match() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::PriorityFirstMatch);
let candidates = vec![
CandidateDecision {
decision: PolicyDecision::Deny("low".into()),
priority: 1,
scope: PolicyScope::Global,
rule_name: "low-rule".into(),
},
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 10,
scope: PolicyScope::Global,
rule_name: "high-rule".into(),
},
];
let result = engine.resolve_conflicts(&candidates);
assert_eq!(result.winning_decision, PolicyDecision::Allow);
assert!(result.conflict_detected);
}
#[test]
fn test_resolve_conflicts_most_specific_wins() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::MostSpecificWins);
let candidates = vec![
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 100,
scope: PolicyScope::Global,
rule_name: "global-allow".into(),
},
CandidateDecision {
decision: PolicyDecision::Deny("agent-deny".into()),
priority: 1,
scope: PolicyScope::Agent,
rule_name: "agent-deny".into(),
},
];
let result = engine.resolve_conflicts(&candidates);
assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
assert!(result.conflict_detected);
}
#[test]
fn test_resolve_conflicts_most_specific_tiebreaker() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::MostSpecificWins);
let candidates = vec![
CandidateDecision {
decision: PolicyDecision::Deny("low".into()),
priority: 1,
scope: PolicyScope::Tenant,
rule_name: "tenant-low".into(),
},
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 10,
scope: PolicyScope::Tenant,
rule_name: "tenant-high".into(),
},
];
let result = engine.resolve_conflicts(&candidates);
assert_eq!(result.winning_decision, PolicyDecision::Allow);
}
#[test]
fn test_policy_rule_priority_and_scope_defaults() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: simple-rule
type: capability
allowed_actions:
- "data.read"
"#;
let profile: PolicyProfile = serde_yaml::from_str(yaml).unwrap();
let rule = &profile.policies[0];
assert_eq!(rule.priority, 0);
assert_eq!(rule.scope, PolicyScope::Global);
}
#[test]
fn test_policy_rule_with_priority_and_scope() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: agent-rule
type: capability
allowed_actions:
- "data.read"
priority: 10
scope: agent
"#;
let profile: PolicyProfile = serde_yaml::from_str(yaml).unwrap();
let rule = &profile.policies[0];
assert_eq!(rule.priority, 10);
assert_eq!(rule.scope, PolicyScope::Agent);
}
#[test]
fn test_no_conflict_when_all_same_decision() {
let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
let candidates = vec![
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 5,
scope: PolicyScope::Global,
rule_name: "r1".into(),
},
CandidateDecision {
decision: PolicyDecision::Allow,
priority: 10,
scope: PolicyScope::Tenant,
rule_name: "r2".into(),
},
];
let result = engine.resolve_conflicts(&candidates);
assert!(!result.conflict_detected);
assert_eq!(result.winning_decision, PolicyDecision::Allow);
}
#[test]
fn test_multiple_capability_rules_first_match_wins() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: deny-first
type: capability
denied_actions:
- "data.read"
- name: allow-second
type: capability
allowed_actions:
- "data.read"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
let decision = engine.evaluate("data.read", None);
assert!(matches!(decision, PolicyDecision::Deny(_)));
}
#[test]
fn test_policy_with_only_deny_rules() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: deny-only
type: capability
denied_actions:
- "shell:*"
- "admin.*"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
assert!(matches!(
engine.evaluate("shell:ls", None),
PolicyDecision::Deny(_)
));
assert!(matches!(
engine.evaluate("admin.delete", None),
PolicyDecision::Deny(_)
));
assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
}
#[test]
fn test_policy_with_only_allow_rules() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: allow-only
type: capability
allowed_actions:
- "data.read"
- "data.write"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
assert_eq!(engine.evaluate("data.write", None), PolicyDecision::Allow);
assert!(matches!(
engine.evaluate("data.delete", None),
PolicyDecision::Deny(_)
));
}
#[test]
fn test_conditions_matching() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: env-gate
type: capability
denied_actions:
- "deploy.*"
conditions:
environment: "production"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
let mut context = HashMap::new();
context.insert(
"environment".to_string(),
serde_yaml::Value::String("production".to_string()),
);
let decision = engine.evaluate("deploy.app", Some(&context));
assert!(matches!(decision, PolicyDecision::Deny(_)));
}
#[test]
fn test_conditions_not_matching() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: env-gate
type: capability
denied_actions:
- "deploy.*"
conditions:
environment: "production"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
let mut context = HashMap::new();
context.insert(
"environment".to_string(),
serde_yaml::Value::String("staging".to_string()),
);
let decision = engine.evaluate("deploy.app", Some(&context));
assert_eq!(decision, PolicyDecision::Allow);
}
#[test]
fn test_conditions_no_context_skips_rule() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: env-gate
type: capability
denied_actions:
- "deploy.*"
conditions:
environment: "production"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
let decision = engine.evaluate("deploy.app", None);
assert_eq!(decision, PolicyDecision::Allow);
}
#[test]
fn test_loading_invalid_yaml_returns_error() {
let engine = PolicyEngine::new();
let result = engine.load_from_yaml("{{not valid yaml");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), PolicyError::InvalidYaml(_)));
}
#[test]
fn test_loading_from_temp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("policy.yaml");
std::fs::write(&path, POLICY_YAML).unwrap();
let engine = PolicyEngine::new();
engine.load_from_file(path.to_str().unwrap()).unwrap();
assert!(engine.is_loaded());
assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
}
#[test]
fn test_multiple_rate_limit_rules_for_different_actions() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: api-limit
type: rate_limit
actions:
- "api.call"
max_calls: 2
window: "60s"
- name: db-limit
type: rate_limit
actions:
- "db.query"
max_calls: 1
window: "60s"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
assert!(matches!(
engine.evaluate("api.call", None),
PolicyDecision::RateLimited { .. }
));
assert_eq!(engine.evaluate("db.query", None), PolicyDecision::Allow);
assert!(matches!(
engine.evaluate("db.query", None),
PolicyDecision::RateLimited { .. }
));
}
#[test]
fn test_rate_limit_resets_after_window() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: fast-limit
type: rate_limit
actions:
- "api.call"
max_calls: 1
window: "0s"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
assert!(matches!(
engine.evaluate("api.call", None),
PolicyDecision::RateLimited { .. }
));
std::thread::sleep(std::time::Duration::from_millis(1100));
assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
}
#[test]
fn test_wildcard_matches_everything() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: deny-all
type: capability
denied_actions:
- "*"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
assert!(matches!(
engine.evaluate("anything", None),
PolicyDecision::Deny(_)
));
assert!(matches!(
engine.evaluate("data.read", None),
PolicyDecision::Deny(_)
));
assert!(matches!(
engine.evaluate("shell:ls", None),
PolicyDecision::Deny(_)
));
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("5m"), 300);
}
#[test]
fn test_parse_duration_seconds() {
assert_eq!(parse_duration("30s"), 30);
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("2h"), 7200);
}
#[test]
fn test_parse_duration_bare_number() {
assert_eq!(parse_duration("120"), 120);
}
#[test]
fn test_is_loaded_false_initially() {
let engine = PolicyEngine::new();
assert!(!engine.is_loaded());
}
#[test]
fn test_is_loaded_true_after_load() {
let engine = PolicyEngine::new();
engine.load_from_yaml(POLICY_YAML).unwrap();
assert!(engine.is_loaded());
}
#[test]
fn test_rules_present_but_none_match_falls_through() {
let yaml = r#"
version: "1.0"
agent: test
policies:
- name: gate
type: capability
denied_actions:
- "shell:*"
"#;
let engine = PolicyEngine::new();
engine.load_from_yaml(yaml).unwrap();
assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
}
#[test]
fn test_action_matches_empty_strings() {
assert!(action_matches("", ""));
assert!(!action_matches("", "data.read"));
assert!(!action_matches("data.read", ""));
}
#[test]
fn test_action_matches_exact_match() {
assert!(action_matches("data.read", "data.read"));
assert!(!action_matches("data.read", "data.write"));
}
#[test]
fn test_action_matches_partial_non_match() {
assert!(!action_matches("data", "data.read"));
assert!(!action_matches("data.rea", "data.read"));
}
}