use super::permission::{Action, Resource, WILDCARD};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum PolicyEffect {
Allow,
#[default]
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subject {
pub id: String,
pub roles: HashSet<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub attributes: HashMap<String, String>,
}
impl Subject {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
roles: HashSet::new(),
attributes: HashMap::new(),
}
}
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.roles.insert(role.into());
self
}
pub fn with_roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.roles.extend(roles.into_iter().map(Into::into));
self
}
pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(key.into(), value.into());
self
}
pub fn id(&self) -> &str {
&self.id
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.contains(role)
}
pub fn has_any_role(&self, roles: &[&str]) -> bool {
roles.iter().any(|r| self.roles.contains(*r))
}
pub fn get_attribute(&self, key: &str) -> Option<&str> {
self.attributes.get(key).map(|s| s.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub effect: PolicyEffect,
#[serde(default)]
pub priority: i32,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub roles: HashSet<String>,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub resources: HashSet<String>,
#[serde(default, skip_serializing_if = "HashSet::is_empty")]
pub actions: HashSet<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conditions: Vec<PolicyCondition>,
#[serde(default = "default_enabled")]
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn default_enabled() -> bool {
true
}
impl Policy {
pub fn allow(id: impl Into<String>) -> PolicyBuilder {
PolicyBuilder::new(id, PolicyEffect::Allow)
}
pub fn deny(id: impl Into<String>) -> PolicyBuilder {
PolicyBuilder::new(id, PolicyEffect::Deny)
}
pub fn matches(&self, subject: &Subject, resource: &Resource, action: &Action) -> bool {
if !self.enabled {
return false;
}
if !self.roles.is_empty() {
let role_matched = self
.roles
.iter()
.any(|r| r == WILDCARD || subject.has_role(r));
if !role_matched {
return false;
}
}
if !self.resources.is_empty() {
let resource_matched = self
.resources
.iter()
.any(|r| r == WILDCARD || resource.matches_name(r));
if !resource_matched {
return false;
}
}
if !self.actions.is_empty() {
let action_matched = self
.actions
.iter()
.any(|a| a == WILDCARD || action.matches(a));
if !action_matched {
return false;
}
}
if !self.conditions.is_empty() {
return self
.conditions
.iter()
.all(|c| c.evaluate(subject, resource, action));
}
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyCondition {
pub condition_type: ConditionType,
pub key: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConditionType {
StringEquals,
StringNotEquals,
StringLike,
IpAddress,
NotIpAddress,
Bool,
DateLessThan,
DateGreaterThan,
}
impl PolicyCondition {
pub fn string_equals(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
condition_type: ConditionType::StringEquals,
key: key.into(),
values: vec![value.into()],
}
}
pub fn string_not_equals(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
condition_type: ConditionType::StringNotEquals,
key: key.into(),
values: vec![value.into()],
}
}
pub fn evaluate(&self, subject: &Subject, resource: &Resource, _action: &Action) -> bool {
let actual_value = match self.key.as_str() {
key if key.starts_with("subject.") => {
let attr_key = &key[8..];
if attr_key == "id" {
Some(subject.id.as_str())
} else {
subject.get_attribute(attr_key)
}
}
key if key.starts_with("resource.") => {
let attr_key = &key[9..];
if attr_key == "name" {
Some(resource.name.as_str())
} else if attr_key == "id" {
resource.id()
} else {
resource.get_attribute(attr_key)
}
}
_ => None,
};
match &self.condition_type {
ConditionType::StringEquals => {
actual_value.is_some_and(|v| self.values.contains(&v.to_string()))
}
ConditionType::StringNotEquals => {
actual_value.is_none_or(|v| !self.values.contains(&v.to_string()))
}
ConditionType::StringLike => actual_value.is_some_and(|v| {
self.values.iter().any(|pattern| {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
v.starts_with(parts[0]) && v.ends_with(parts[1])
} else {
v.contains(pattern.trim_matches('*'))
}
} else {
v == pattern
}
})
}),
_ => true, }
}
}
pub struct PolicyBuilder {
id: String,
name: Option<String>,
description: Option<String>,
effect: PolicyEffect,
priority: i32,
roles: HashSet<String>,
resources: HashSet<String>,
actions: HashSet<String>,
conditions: Vec<PolicyCondition>,
enabled: bool,
}
impl PolicyBuilder {
pub fn new(id: impl Into<String>, effect: PolicyEffect) -> Self {
Self {
id: id.into(),
name: None,
description: None,
effect,
priority: 0,
roles: HashSet::new(),
resources: HashSet::new(),
actions: HashSet::new(),
conditions: Vec::new(),
enabled: true,
}
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn role(mut self, role: impl Into<String>) -> Self {
self.roles.insert(role.into());
self
}
pub fn roles<I, S>(mut self, roles: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.roles.extend(roles.into_iter().map(Into::into));
self
}
pub fn resource(mut self, resource: impl Into<String>) -> Self {
self.resources.insert(resource.into());
self
}
pub fn resources<I, S>(mut self, resources: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.resources.extend(resources.into_iter().map(Into::into));
self
}
pub fn action(mut self, action: impl Into<String>) -> Self {
self.actions.insert(action.into());
self
}
pub fn actions<I, S>(mut self, actions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.actions.extend(actions.into_iter().map(Into::into));
self
}
pub fn condition(mut self, condition: PolicyCondition) -> Self {
self.conditions.push(condition);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn build(self) -> Policy {
let now = Utc::now();
Policy {
id: self.id,
name: self.name,
description: self.description,
effect: self.effect,
priority: self.priority,
roles: self.roles,
resources: self.resources,
actions: self.actions,
conditions: self.conditions,
enabled: self.enabled,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone)]
pub struct Decision {
pub effect: PolicyEffect,
pub reason: DecisionReason,
pub matched_policy: Option<String>,
}
impl Decision {
pub fn allow(policy_id: impl Into<String>) -> Self {
Self {
effect: PolicyEffect::Allow,
reason: DecisionReason::PolicyMatched,
matched_policy: Some(policy_id.into()),
}
}
pub fn deny(policy_id: impl Into<String>) -> Self {
Self {
effect: PolicyEffect::Deny,
reason: DecisionReason::PolicyMatched,
matched_policy: Some(policy_id.into()),
}
}
pub fn default_deny() -> Self {
Self {
effect: PolicyEffect::Deny,
reason: DecisionReason::NoMatchingPolicy,
matched_policy: None,
}
}
pub fn is_allowed(&self) -> bool {
self.effect == PolicyEffect::Allow
}
pub fn is_denied(&self) -> bool {
self.effect == PolicyEffect::Deny
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecisionReason {
PolicyMatched,
NoMatchingPolicy,
ExplicitDeny,
}
pub trait PolicyEvaluator {
fn evaluate(&self, subject: &Subject, resource: &Resource, action: &Action) -> Decision;
}
#[derive(Debug, Default)]
pub struct PolicyEngine {
policies: HashMap<String, Policy>,
}
impl PolicyEngine {
pub fn new() -> Self {
Self {
policies: HashMap::new(),
}
}
pub fn add_policy(&mut self, policy: Policy) {
self.policies.insert(policy.id.clone(), policy);
}
pub fn remove_policy(&mut self, id: &str) -> Option<Policy> {
self.policies.remove(id)
}
pub fn get_policy(&self, id: &str) -> Option<&Policy> {
self.policies.get(id)
}
pub fn get_policy_mut(&mut self, id: &str) -> Option<&mut Policy> {
self.policies.get_mut(id)
}
pub fn list_policies(&self) -> Vec<&Policy> {
self.policies.values().collect()
}
pub fn policy_count(&self) -> usize {
self.policies.len()
}
pub fn clear(&mut self) {
self.policies.clear();
}
fn get_matching_policies(
&self,
subject: &Subject,
resource: &Resource,
action: &Action,
) -> Vec<&Policy> {
let mut matching: Vec<_> = self
.policies
.values()
.filter(|p| p.matches(subject, resource, action))
.collect();
matching.sort_by(|a, b| b.priority.cmp(&a.priority));
matching
}
pub fn check_permission(&self, subject: &Subject, resource: &str, action: &str) -> bool {
self.evaluate(subject, &Resource::new(resource), &Action::new(action))
.is_allowed()
}
pub fn check_permissions(
&self,
subject: &Subject,
permissions: &[(String, String)],
) -> HashMap<(String, String), bool> {
permissions
.iter()
.map(|(resource, action)| {
let allowed = self.check_permission(subject, resource, action);
((resource.clone(), action.clone()), allowed)
})
.collect()
}
}
impl PolicyEvaluator for PolicyEngine {
fn evaluate(&self, subject: &Subject, resource: &Resource, action: &Action) -> Decision {
let matching = self.get_matching_policies(subject, resource, action);
if matching.is_empty() {
return Decision::default_deny();
}
for policy in &matching {
if policy.effect == PolicyEffect::Deny {
return Decision::deny(&policy.id);
}
}
for policy in &matching {
if policy.effect == PolicyEffect::Allow {
return Decision::allow(&policy.id);
}
}
Decision::default_deny()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subject() {
let subject = Subject::new("user1")
.with_role("editor")
.with_role("viewer")
.with_attribute("department", "engineering");
assert_eq!(subject.id(), "user1");
assert!(subject.has_role("editor"));
assert!(subject.has_role("viewer"));
assert!(!subject.has_role("admin"));
assert_eq!(subject.get_attribute("department"), Some("engineering"));
}
#[test]
fn test_policy_builder() {
let policy = Policy::allow("test-policy")
.name("Test Policy")
.description("A test policy")
.priority(10)
.role("editor")
.resource("posts")
.action("read")
.build();
assert_eq!(policy.id, "test-policy");
assert_eq!(policy.name, Some("Test Policy".to_string()));
assert_eq!(policy.effect, PolicyEffect::Allow);
assert_eq!(policy.priority, 10);
assert!(policy.roles.contains("editor"));
assert!(policy.resources.contains("posts"));
assert!(policy.actions.contains("read"));
}
#[test]
fn test_policy_matching() {
let policy = Policy::allow("editor-posts")
.role("editor")
.resource("posts")
.action("read")
.build();
let editor = Subject::new("user1").with_role("editor");
let viewer = Subject::new("user2").with_role("viewer");
let posts = Resource::new("posts");
let users = Resource::new("users");
let read = Action::new("read");
let write = Action::new("write");
assert!(policy.matches(&editor, &posts, &read));
assert!(!policy.matches(&viewer, &posts, &read));
assert!(!policy.matches(&editor, &users, &read));
assert!(!policy.matches(&editor, &posts, &write));
}
#[test]
fn test_policy_wildcard_matching() {
let policy = Policy::allow("admin-all")
.role("admin")
.resource("*")
.action("*")
.build();
let admin = Subject::new("admin1").with_role("admin");
let posts = Resource::new("posts");
let users = Resource::new("users");
let read = Action::new("read");
let delete = Action::new("delete");
assert!(policy.matches(&admin, &posts, &read));
assert!(policy.matches(&admin, &users, &delete));
}
#[test]
fn test_policy_engine_basic() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::allow("editor-posts-read")
.role("editor")
.resource("posts")
.action("read")
.build(),
);
let editor = Subject::new("user1").with_role("editor");
let viewer = Subject::new("user2").with_role("viewer");
let decision = engine.evaluate(&editor, &Resource::new("posts"), &Action::new("read"));
assert!(decision.is_allowed());
let decision = engine.evaluate(&viewer, &Resource::new("posts"), &Action::new("read"));
assert!(decision.is_denied());
}
#[test]
fn test_policy_engine_deny_priority() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::allow("editor-posts-all")
.role("editor")
.resource("posts")
.action("*")
.build(),
);
engine.add_policy(
Policy::deny("no-delete")
.resource("posts")
.action("delete")
.priority(100)
.build(),
);
let editor = Subject::new("user1").with_role("editor");
let decision = engine.evaluate(&editor, &Resource::new("posts"), &Action::new("read"));
assert!(decision.is_allowed());
let decision = engine.evaluate(&editor, &Resource::new("posts"), &Action::new("delete"));
assert!(decision.is_denied());
}
#[test]
fn test_policy_engine_multiple_roles() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::allow("viewer-read")
.role("viewer")
.resource("posts")
.action("read")
.build(),
);
engine.add_policy(
Policy::allow("editor-write")
.role("editor")
.resource("posts")
.action("write")
.build(),
);
let user = Subject::new("user1")
.with_role("viewer")
.with_role("editor");
assert!(engine.check_permission(&user, "posts", "read"));
assert!(engine.check_permission(&user, "posts", "write"));
assert!(!engine.check_permission(&user, "posts", "delete"));
}
#[test]
fn test_policy_condition() {
let condition = PolicyCondition::string_equals("subject.department", "engineering");
let user_eng = Subject::new("user1").with_attribute("department", "engineering");
let user_sales = Subject::new("user2").with_attribute("department", "sales");
let resource = Resource::new("code");
let action = Action::new("read");
assert!(condition.evaluate(&user_eng, &resource, &action));
assert!(!condition.evaluate(&user_sales, &resource, &action));
}
#[test]
fn test_policy_with_condition() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::allow("eng-code-access")
.resource("code")
.action("read")
.condition(PolicyCondition::string_equals(
"subject.department",
"engineering",
))
.build(),
);
let user_eng = Subject::new("user1").with_attribute("department", "engineering");
let user_sales = Subject::new("user2").with_attribute("department", "sales");
assert!(engine.check_permission(&user_eng, "code", "read"));
assert!(!engine.check_permission(&user_sales, "code", "read"));
}
#[test]
fn test_disabled_policy() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::allow("disabled-policy")
.role("editor")
.resource("posts")
.action("read")
.enabled(false)
.build(),
);
let editor = Subject::new("user1").with_role("editor");
let decision = engine.evaluate(&editor, &Resource::new("posts"), &Action::new("read"));
assert!(decision.is_denied());
assert_eq!(decision.reason, DecisionReason::NoMatchingPolicy);
}
#[test]
fn test_decision() {
let allow = Decision::allow("test-policy");
assert!(allow.is_allowed());
assert!(!allow.is_denied());
let deny = Decision::deny("test-policy");
assert!(deny.is_denied());
assert!(!deny.is_allowed());
let default_deny = Decision::default_deny();
assert!(default_deny.is_denied());
assert_eq!(default_deny.reason, DecisionReason::NoMatchingPolicy);
}
#[test]
fn test_batch_check_permissions() {
let mut engine = PolicyEngine::new();
engine.add_policy(
Policy::allow("editor-posts")
.role("editor")
.resource("posts")
.actions(["read", "write"])
.build(),
);
let editor = Subject::new("user1").with_role("editor");
let permissions = vec![
("posts".to_string(), "read".to_string()),
("posts".to_string(), "write".to_string()),
("posts".to_string(), "delete".to_string()),
("users".to_string(), "read".to_string()),
];
let results = engine.check_permissions(&editor, &permissions);
assert!(results[&("posts".to_string(), "read".to_string())]);
assert!(results[&("posts".to_string(), "write".to_string())]);
assert!(!results[&("posts".to_string(), "delete".to_string())]);
assert!(!results[&("users".to_string(), "read".to_string())]);
}
}