#![allow(clippy::type_complexity)]
use async_trait::async_trait;
use std::fmt;
use std::sync::Arc;
#[derive(Debug, PartialEq, Clone)]
pub enum CombineOp {
And,
Or,
Not,
}
impl fmt::Display for CombineOp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CombineOp::And => write!(f, "AND"),
CombineOp::Or => write!(f, "OR"),
CombineOp::Not => write!(f, "NOT"),
}
}
}
#[derive(Debug, Clone)]
pub enum PolicyEvalResult {
Granted {
policy_type: String,
reason: Option<String>,
},
Denied { policy_type: String, reason: String },
Combined {
policy_type: String,
operation: CombineOp,
children: Vec<PolicyEvalResult>,
outcome: bool,
},
}
#[derive(Debug, Clone)]
pub enum AccessEvaluation {
Granted {
policy_type: String,
reason: Option<String>,
trace: EvalTrace,
},
Denied {
trace: EvalTrace,
reason: String,
},
}
impl AccessEvaluation {
pub fn is_granted(&self) -> bool {
matches!(self, Self::Granted { .. })
}
pub fn to_result<E>(&self, error_fn: impl FnOnce(&str) -> E) -> Result<(), E> {
match self {
Self::Granted { .. } => Ok(()),
Self::Denied { reason, .. } => Err(error_fn(reason)),
}
}
pub fn display_trace(&self) -> String {
let trace = match self {
AccessEvaluation::Granted {
policy_type: _,
reason: _,
trace,
} => trace,
AccessEvaluation::Denied { reason: _, trace } => trace,
};
let trace_str = trace.format();
if trace_str == "No evaluation trace available" {
format!("{}\n(No evaluation trace available)", self)
} else {
format!("{}\nEvaluation Trace:\n{}", self, trace_str)
}
}
}
impl fmt::Display for AccessEvaluation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Granted {
policy_type,
reason,
trace: _,
} => {
match reason {
Some(r) => write!(f, "[GRANTED] by {} - {}", policy_type, r),
None => write!(f, "[GRANTED] by {}", policy_type),
}
}
Self::Denied { reason, trace: _ } => {
write!(f, "[Denied] - {}", reason)
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EvalTrace {
root: Option<PolicyEvalResult>,
}
impl EvalTrace {
pub fn new() -> Self {
Self { root: None }
}
pub fn with_root(result: PolicyEvalResult) -> Self {
Self { root: Some(result) }
}
pub fn set_root(&mut self, result: PolicyEvalResult) {
self.root = Some(result);
}
pub fn root(&self) -> Option<&PolicyEvalResult> {
self.root.as_ref()
}
pub fn format(&self) -> String {
match &self.root {
Some(root) => root.format(0),
None => "No evaluation trace available".to_string(),
}
}
}
impl PolicyEvalResult {
pub fn is_granted(&self) -> bool {
match self {
Self::Granted { .. } => true,
Self::Denied { .. } => false,
Self::Combined { outcome, .. } => *outcome,
}
}
pub fn reason(&self) -> Option<String> {
match self {
Self::Granted { reason, .. } => reason.clone(),
Self::Denied { reason, .. } => Some(reason.clone()),
Self::Combined { .. } => None,
}
}
pub fn format(&self, indent: usize) -> String {
let indent_str = " ".repeat(indent);
match self {
Self::Granted {
policy_type,
reason,
} => {
let reason_text = reason
.as_ref()
.map_or("".to_string(), |r| format!(": {}", r));
format!("{}✔ {} GRANTED{}", indent_str, policy_type, reason_text)
}
Self::Denied {
policy_type,
reason,
} => {
format!("{}✘ {} DENIED: {}", indent_str, policy_type, reason)
}
Self::Combined {
policy_type,
operation,
children,
outcome,
} => {
let outcome_char = if *outcome { "✔" } else { "✘" };
let mut result = format!(
"{}{} {} ({})",
indent_str, outcome_char, policy_type, operation
);
for child in children {
result.push_str(&format!("\n{}", child.format(indent + 2)));
}
result
}
}
}
}
impl fmt::Display for PolicyEvalResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let tree = self.format(0);
write!(f, "{}", tree)
}
}
#[async_trait]
pub trait Policy<Subject, Resource, Action, Context>: Send + Sync {
async fn evaluate_access(
&self,
subject: &Subject,
action: &Action,
resource: &Resource,
context: &Context,
) -> PolicyEvalResult;
fn policy_type(&self) -> String;
}
#[derive(Clone)]
pub struct PermissionChecker<S, R, A, C> {
policies: Vec<Arc<dyn Policy<S, R, A, C>>>,
}
impl<S, R, A, C> Default for PermissionChecker<S, R, A, C> {
fn default() -> Self {
Self::new()
}
}
impl<S, R, A, C> PermissionChecker<S, R, A, C> {
pub fn new() -> Self {
Self {
policies: Vec::new(),
}
}
pub fn add_policy<P: Policy<S, R, A, C> + 'static>(&mut self, policy: P) {
self.policies.push(Arc::new(policy));
}
#[tracing::instrument(skip_all)]
pub async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> AccessEvaluation {
if self.policies.is_empty() {
tracing::debug!("No policies configured");
let result = PolicyEvalResult::Denied {
policy_type: "PermissionChecker".to_string(),
reason: "No policies configured".to_string(),
};
return AccessEvaluation::Denied {
trace: EvalTrace::with_root(result),
reason: "No policies configured".to_string(),
};
}
tracing::trace!(num_policies = self.policies.len(), "Checking access");
let mut policy_results = Vec::new();
for policy in &self.policies {
let result = policy
.evaluate_access(subject, action, resource, context)
.await;
let result_passes = result.is_granted();
policy_results.push(result.clone());
if result_passes {
let combined = PolicyEvalResult::Combined {
policy_type: "PermissionChecker".to_string(),
operation: CombineOp::Or,
children: policy_results,
outcome: true,
};
return AccessEvaluation::Granted {
policy_type: policy.policy_type(),
reason: result.reason(),
trace: EvalTrace::with_root(combined),
};
}
}
tracing::trace!("No policies allowed access, returning Forbidden");
let combined = PolicyEvalResult::Combined {
policy_type: "PermissionChecker".to_string(),
operation: CombineOp::Or,
children: policy_results,
outcome: false,
};
AccessEvaluation::Denied {
trace: EvalTrace::with_root(combined),
reason: "All policies denied access".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
Allow,
Deny,
}
struct InternalPolicy<S, R, A, C> {
name: String,
effect: Effect,
predicate: Box<dyn Fn(&S, &A, &R, &C) -> bool + Send + Sync>,
}
#[async_trait]
impl<S, R, A, C> Policy<S, R, A, C> for InternalPolicy<S, R, A, C>
where
S: Send + Sync,
R: Send + Sync,
A: Send + Sync,
C: Send + Sync,
{
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> PolicyEvalResult {
if (self.predicate)(subject, action, resource, context) {
match self.effect {
Effect::Allow => PolicyEvalResult::Granted {
policy_type: self.name.clone(),
reason: Some("Policy allowed access".into()),
},
Effect::Deny => PolicyEvalResult::Denied {
policy_type: self.name.clone(),
reason: "Policy denied access".into(),
},
}
} else {
PolicyEvalResult::Denied {
policy_type: self.name.clone(),
reason: "Policy predicate did not match".into(),
}
}
}
fn policy_type(&self) -> String {
self.name.clone()
}
}
#[async_trait]
impl<S, R, A, C> Policy<S, R, A, C> for Box<dyn Policy<S, R, A, C>>
where
S: Send + Sync,
R: Send + Sync,
A: Send + Sync,
C: Send + Sync,
{
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> PolicyEvalResult {
(**self)
.evaluate_access(subject, action, resource, context)
.await
}
fn policy_type(&self) -> String {
(**self).policy_type()
}
}
pub struct PolicyBuilder<S, R, A, C>
where
S: Send + Sync + 'static,
R: Send + Sync + 'static,
A: Send + Sync + 'static,
C: Send + Sync + 'static,
{
name: String,
effect: Effect,
subject_pred: Option<Box<dyn Fn(&S) -> bool + Send + Sync>>,
action_pred: Option<Box<dyn Fn(&A) -> bool + Send + Sync>>,
resource_pred: Option<Box<dyn Fn(&R) -> bool + Send + Sync>>,
context_pred: Option<Box<dyn Fn(&C) -> bool + Send + Sync>>,
extra_condition: Option<Box<dyn Fn(&S, &A, &R, &C) -> bool + Send + Sync>>,
}
impl<Subject, Resource, Action, Context> PolicyBuilder<Subject, Resource, Action, Context>
where
Subject: Send + Sync + 'static,
Resource: Send + Sync + 'static,
Action: Send + Sync + 'static,
Context: Send + Sync + 'static,
{
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
effect: Effect::Allow,
subject_pred: None,
action_pred: None,
resource_pred: None,
context_pred: None,
extra_condition: None,
}
}
pub fn effect(mut self, effect: Effect) -> Self {
self.effect = effect;
self
}
pub fn subjects<F>(mut self, pred: F) -> Self
where
F: Fn(&Subject) -> bool + Send + Sync + 'static,
{
self.subject_pred = Some(Box::new(pred));
self
}
pub fn actions<F>(mut self, pred: F) -> Self
where
F: Fn(&Action) -> bool + Send + Sync + 'static,
{
self.action_pred = Some(Box::new(pred));
self
}
pub fn resources<F>(mut self, pred: F) -> Self
where
F: Fn(&Resource) -> bool + Send + Sync + 'static,
{
self.resource_pred = Some(Box::new(pred));
self
}
pub fn context<F>(mut self, pred: F) -> Self
where
F: Fn(&Context) -> bool + Send + Sync + 'static,
{
self.context_pred = Some(Box::new(pred));
self
}
pub fn when<F>(mut self, pred: F) -> Self
where
F: Fn(&Subject, &Action, &Resource, &Context) -> bool + Send + Sync + 'static,
{
self.extra_condition = Some(Box::new(pred));
self
}
pub fn build(self) -> Box<dyn Policy<Subject, Resource, Action, Context>> {
let effect = self.effect;
let subject_pred = self.subject_pred;
let action_pred = self.action_pred;
let resource_pred = self.resource_pred;
let context_pred = self.context_pred;
let extra_condition = self.extra_condition;
let predicate = Box::new(move |s: &Subject, a: &Action, r: &Resource, c: &Context| {
subject_pred.as_ref().is_none_or(|f| f(s))
&& action_pred.as_ref().is_none_or(|f| f(a))
&& resource_pred.as_ref().is_none_or(|f| f(r))
&& context_pred.as_ref().is_none_or(|f| f(c))
&& extra_condition.as_ref().is_none_or(|f| f(s, a, r, c))
});
Box::new(InternalPolicy {
name: self.name,
effect,
predicate,
})
}
}
pub struct RbacPolicy<S, F1, F2> {
required_roles_resolver: F1,
user_roles_resolver: F2,
_marker: std::marker::PhantomData<S>,
}
impl<S, F1, F2> RbacPolicy<S, F1, F2> {
pub fn new(required_roles_resolver: F1, user_roles_resolver: F2) -> Self {
Self {
required_roles_resolver,
user_roles_resolver,
_marker: std::marker::PhantomData,
}
}
}
#[async_trait]
impl<S, R, A, C, F1, F2> Policy<S, R, A, C> for RbacPolicy<S, F1, F2>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
F1: Fn(&R, &A) -> Vec<uuid::Uuid> + Sync + Send,
F2: Fn(&S) -> Vec<uuid::Uuid> + Sync + Send,
{
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
_context: &C,
) -> PolicyEvalResult {
let required_roles = (self.required_roles_resolver)(resource, action);
let user_roles = (self.user_roles_resolver)(subject);
let has_role = required_roles.iter().any(|role| user_roles.contains(role));
if has_role {
PolicyEvalResult::Granted {
policy_type: Policy::<S, R, A, C>::policy_type(self),
reason: Some("User has required role".to_string()),
}
} else {
PolicyEvalResult::Denied {
policy_type: Policy::<S, R, A, C>::policy_type(self),
reason: "User doesn't have required role".to_string(),
}
}
}
fn policy_type(&self) -> String {
"RbacPolicy".to_string()
}
}
pub struct AbacPolicy<S, R, A, C, F> {
condition: F,
_marker: std::marker::PhantomData<(S, R, A, C)>,
}
impl<S, R, A, C, F> AbacPolicy<S, R, A, C, F> {
pub fn new(condition: F) -> Self {
Self {
condition,
_marker: std::marker::PhantomData,
}
}
}
#[async_trait]
impl<S, R, A, C, F> Policy<S, R, A, C> for AbacPolicy<S, R, A, C, F>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
F: Fn(&S, &R, &A, &C) -> bool + Sync + Send,
{
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> PolicyEvalResult {
let condition_met = (self.condition)(subject, resource, action, context);
if condition_met {
PolicyEvalResult::Granted {
policy_type: self.policy_type(),
reason: Some("Condition evaluated to true".to_string()),
}
} else {
PolicyEvalResult::Denied {
policy_type: self.policy_type(),
reason: "Condition evaluated to false".to_string(),
}
}
}
fn policy_type(&self) -> String {
"AbacPolicy".to_string()
}
}
#[async_trait]
pub trait RelationshipResolver<S, R>: Send + Sync {
async fn has_relationship(&self, subject: &S, resource: &R, relationship: &str) -> bool;
}
pub struct RebacPolicy<S, R, A, C, RG> {
pub relationship: String,
pub resolver: RG,
_marker: std::marker::PhantomData<(S, R, A, C)>,
}
impl<S, R, A, C, RG> RebacPolicy<S, R, A, C, RG> {
pub fn new(relationship: impl Into<String>, resolver: RG) -> Self {
Self {
relationship: relationship.into(),
resolver,
_marker: std::marker::PhantomData,
}
}
}
#[async_trait]
impl<S, R, A, C, RG> Policy<S, R, A, C> for RebacPolicy<S, R, A, C, RG>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
RG: RelationshipResolver<S, R> + Send + Sync,
{
async fn evaluate_access(
&self,
subject: &S,
_action: &A,
resource: &R,
_context: &C,
) -> PolicyEvalResult {
let has_relationship = self
.resolver
.has_relationship(subject, resource, &self.relationship)
.await;
if has_relationship {
PolicyEvalResult::Granted {
policy_type: self.policy_type(),
reason: Some(format!(
"Subject has '{}' relationship with resource",
self.relationship
)),
}
} else {
PolicyEvalResult::Denied {
policy_type: self.policy_type(),
reason: format!(
"Subject does not have '{}' relationship with resource",
self.relationship
),
}
}
}
fn policy_type(&self) -> String {
"RebacPolicy".to_string()
}
}
pub struct AndPolicy<S, R, A, C> {
policies: Vec<Arc<dyn Policy<S, R, A, C>>>,
}
#[derive(Debug, Copy, Clone)]
pub struct EmptyPoliciesError(pub &'static str);
impl<S, R, A, C> AndPolicy<S, R, A, C> {
pub fn try_new(policies: Vec<Arc<dyn Policy<S, R, A, C>>>) -> Result<Self, EmptyPoliciesError> {
if policies.is_empty() {
Err(EmptyPoliciesError(
"AndPolicy must have at least one policy",
))
} else {
Ok(Self { policies })
}
}
}
#[async_trait]
impl<S, R, A, C> Policy<S, R, A, C> for AndPolicy<S, R, A, C>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
{
fn policy_type(&self) -> String {
"AndPolicy".to_string()
}
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> PolicyEvalResult {
let mut children_results = Vec::new();
for policy in &self.policies {
let result = policy
.evaluate_access(subject, action, resource, context)
.await;
children_results.push(result.clone());
if !result.is_granted() {
return PolicyEvalResult::Combined {
policy_type: self.policy_type(),
operation: CombineOp::And,
children: children_results,
outcome: false,
};
}
}
PolicyEvalResult::Combined {
policy_type: self.policy_type(),
operation: CombineOp::And,
children: children_results,
outcome: true,
}
}
}
pub struct OrPolicy<S, R, A, C> {
policies: Vec<Arc<dyn Policy<S, R, A, C>>>,
}
impl<S, R, A, C> OrPolicy<S, R, A, C> {
pub fn try_new(policies: Vec<Arc<dyn Policy<S, R, A, C>>>) -> Result<Self, EmptyPoliciesError> {
if policies.is_empty() {
Err(EmptyPoliciesError("OrPolicy must have at least one policy"))
} else {
Ok(Self { policies })
}
}
}
#[async_trait]
impl<S, R, A, C> Policy<S, R, A, C> for OrPolicy<S, R, A, C>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
{
fn policy_type(&self) -> String {
"OrPolicy".to_string()
}
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> PolicyEvalResult {
let mut children_results = Vec::new();
for policy in &self.policies {
let result = policy
.evaluate_access(subject, action, resource, context)
.await;
children_results.push(result.clone());
if result.is_granted() {
return PolicyEvalResult::Combined {
policy_type: self.policy_type(),
operation: CombineOp::Or,
children: children_results,
outcome: true,
};
}
}
PolicyEvalResult::Combined {
policy_type: self.policy_type(),
operation: CombineOp::Or,
children: children_results,
outcome: false,
}
}
}
pub struct NotPolicy<S, R, A, C> {
policy: Arc<dyn Policy<S, R, A, C>>,
}
impl<S, R, A, C> NotPolicy<S, R, A, C> {
pub fn new(policy: impl Policy<S, R, A, C> + 'static) -> Self {
Self {
policy: Arc::new(policy),
}
}
}
#[async_trait]
impl<S, R, A, C> Policy<S, R, A, C> for NotPolicy<S, R, A, C>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
{
fn policy_type(&self) -> String {
"NotPolicy".to_string()
}
async fn evaluate_access(
&self,
subject: &S,
action: &A,
resource: &R,
context: &C,
) -> PolicyEvalResult {
let inner_result = self
.policy
.evaluate_access(subject, action, resource, context)
.await;
PolicyEvalResult::Combined {
policy_type: Policy::<S, R, A, C>::policy_type(self),
operation: CombineOp::Not,
children: vec![inner_result.clone()],
outcome: !inner_result.is_granted(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone)]
pub struct TestSubject {
pub id: uuid::Uuid,
}
#[derive(Debug, Clone)]
pub struct TestResource {
pub id: uuid::Uuid,
}
#[derive(Debug, Clone)]
pub struct TestAction;
#[derive(Debug, Clone)]
pub struct TestContext;
struct AlwaysAllowPolicy;
#[async_trait]
impl Policy<TestSubject, TestResource, TestAction, TestContext> for AlwaysAllowPolicy {
async fn evaluate_access(
&self,
_subject: &TestSubject,
_action: &TestAction,
_resource: &TestResource,
_context: &TestContext,
) -> PolicyEvalResult {
PolicyEvalResult::Granted {
policy_type: self.policy_type(),
reason: Some("Always allow policy".to_string()),
}
}
fn policy_type(&self) -> String {
"AlwaysAllowPolicy".to_string()
}
}
struct AlwaysDenyPolicy(&'static str);
#[async_trait]
impl Policy<TestSubject, TestResource, TestAction, TestContext> for AlwaysDenyPolicy {
async fn evaluate_access(
&self,
_subject: &TestSubject,
_action: &TestAction,
_resource: &TestResource,
_context: &TestContext,
) -> PolicyEvalResult {
PolicyEvalResult::Denied {
policy_type: self.policy_type(),
reason: self.0.to_string(),
}
}
fn policy_type(&self) -> String {
"AlwaysDenyPolicy".to_string()
}
}
#[tokio::test]
async fn test_no_policies() {
let checker =
PermissionChecker::<TestSubject, TestResource, TestAction, TestContext>::new();
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = checker
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
match result {
AccessEvaluation::Denied { reason, trace: _ } => {
assert!(reason.contains("No policies configured"));
}
_ => panic!("Expected Denied(No policies configured), got {:?}", result),
}
}
#[tokio::test]
async fn test_one_policy_allow() {
let mut checker = PermissionChecker::new();
checker.add_policy(AlwaysAllowPolicy);
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = checker
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
if let AccessEvaluation::Granted {
policy_type,
reason,
trace,
} = result
{
assert_eq!(policy_type, "AlwaysAllowPolicy");
assert_eq!(reason, Some("Always allow policy".to_string()));
let trace_str = trace.format();
assert!(trace_str.contains("AlwaysAllowPolicy"));
} else {
panic!("Expected AccessEvaluation::Granted, got {:?}", result);
}
}
#[tokio::test]
async fn test_one_policy_deny() {
let mut checker = PermissionChecker::new();
checker.add_policy(AlwaysDenyPolicy("DeniedByPolicy"));
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = checker
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(!result.is_granted());
if let AccessEvaluation::Denied { reason, trace } = result {
assert!(reason.contains("All policies denied access"));
let trace_str = trace.format();
assert!(trace_str.contains("DeniedByPolicy"));
} else {
panic!("Expected AccessEvaluation::Denied, got {:?}", result);
}
}
#[tokio::test]
async fn test_multiple_policies_or_success() {
let mut checker = PermissionChecker::new();
checker.add_policy(AlwaysDenyPolicy("DenyPolicy"));
checker.add_policy(AlwaysAllowPolicy);
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = checker
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
if let AccessEvaluation::Granted {
policy_type,
trace,
reason: _,
} = result
{
assert_eq!(policy_type, "AlwaysAllowPolicy");
let trace_str = trace.format();
assert!(trace_str.contains("DenyPolicy"));
} else {
panic!("Expected AccessEvaluation::Granted, got {:?}", result);
}
}
#[tokio::test]
async fn test_multiple_policies_all_deny_collect_reasons() {
let mut checker = PermissionChecker::new();
checker.add_policy(AlwaysDenyPolicy("DenyPolicy1"));
checker.add_policy(AlwaysDenyPolicy("DenyPolicy2"));
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = checker
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
if let AccessEvaluation::Denied { trace, reason } = result {
let trace_str = trace.format();
assert!(trace_str.contains("DenyPolicy1"));
assert!(trace_str.contains("DenyPolicy2"));
assert_eq!(reason, "All policies denied access");
} else {
panic!("Expected AccessEvaluation::Denied, got {:?}", result);
}
}
pub struct DummyRelationshipResolver {
relationships: Vec<(uuid::Uuid, uuid::Uuid, String)>,
}
impl DummyRelationshipResolver {
pub fn new(relationships: Vec<(uuid::Uuid, uuid::Uuid, String)>) -> Self {
Self { relationships }
}
}
#[async_trait]
impl RelationshipResolver<TestSubject, TestResource> for DummyRelationshipResolver {
async fn has_relationship(
&self,
subject: &TestSubject,
resource: &TestResource,
relationship: &str,
) -> bool {
self.relationships
.iter()
.any(|(s, r, rel)| s == &subject.id && r == &resource.id && rel == relationship)
}
}
#[tokio::test]
async fn test_rebac_policy_allows_when_relationship_exists() {
let subject_id = uuid::Uuid::new_v4();
let resource_id = uuid::Uuid::new_v4();
let relationship = "manager";
let subject = TestSubject { id: subject_id };
let resource = TestResource { id: resource_id };
let resolver = DummyRelationshipResolver::new(vec![(
subject_id,
resource_id,
relationship.to_string(),
)]);
let policy = RebacPolicy::<TestSubject, TestResource, TestAction, TestContext, _>::new(
relationship,
resolver,
);
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
result.is_granted(),
"Access should be allowed if relationship exists"
);
}
#[tokio::test]
async fn test_rebac_policy_denies_when_relationship_missing() {
let subject_id = uuid::Uuid::new_v4();
let resource_id = uuid::Uuid::new_v4();
let relationship = "manager";
let subject = TestSubject { id: subject_id };
let resource = TestResource { id: resource_id };
let resolver = DummyRelationshipResolver::new(vec![]);
let policy = RebacPolicy::<TestSubject, TestResource, TestAction, TestContext, _>::new(
relationship,
resolver,
);
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
!result.is_granted(),
"Access should be denied if relationship does not exist"
);
}
#[tokio::test]
async fn test_and_policy_allows_when_all_allow() {
let policy = AndPolicy::try_new(vec![
Arc::new(AlwaysAllowPolicy),
Arc::new(AlwaysAllowPolicy),
])
.expect("Unable to create and-policy policy");
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
result.is_granted(),
"AndPolicy should allow access when all inner policies allow"
);
}
#[tokio::test]
async fn test_and_policy_denies_when_one_denies() {
let policy = AndPolicy::try_new(vec![
Arc::new(AlwaysAllowPolicy),
Arc::new(AlwaysDenyPolicy("DenyInAnd")),
])
.expect("Unable to create and-policy policy");
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
match result {
PolicyEvalResult::Combined {
policy_type,
operation,
children,
outcome,
} => {
assert_eq!(operation, CombineOp::And);
assert!(!outcome);
assert_eq!(children.len(), 2);
assert!(children[1].format(0).contains("DenyInAnd"));
assert_eq!(policy_type, "AndPolicy");
}
_ => panic!("Expected Combined result from AndPolicy, got {:?}", result),
}
}
#[tokio::test]
async fn test_or_policy_allows_when_one_allows() {
let policy = OrPolicy::try_new(vec![
Arc::new(AlwaysDenyPolicy("Deny1")),
Arc::new(AlwaysAllowPolicy),
])
.expect("Unable to create or-policy policy");
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
result.is_granted(),
"OrPolicy should allow access when at least one inner policy allows"
);
}
#[tokio::test]
async fn test_or_policy_denies_when_all_deny() {
let policy = OrPolicy::try_new(vec![
Arc::new(AlwaysDenyPolicy("Deny1")),
Arc::new(AlwaysDenyPolicy("Deny2")),
])
.expect("Unable to create or-policy policy");
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
match result {
PolicyEvalResult::Combined {
policy_type,
operation,
children,
outcome,
} => {
assert_eq!(operation, CombineOp::Or);
assert!(!outcome);
assert_eq!(children.len(), 2);
assert!(children[0].format(0).contains("Deny1"));
assert!(children[1].format(0).contains("Deny2"));
assert_eq!(policy_type, "OrPolicy");
}
_ => panic!("Expected Combined result from OrPolicy, got {:?}", result),
}
}
#[tokio::test]
async fn test_not_policy_allows_when_inner_denies() {
let policy = NotPolicy::new(AlwaysDenyPolicy("AlwaysDeny"));
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
result.is_granted(),
"NotPolicy should allow access when inner policy denies"
);
}
#[tokio::test]
async fn test_not_policy_denies_when_inner_allows() {
let policy = NotPolicy::new(AlwaysAllowPolicy);
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
match result {
PolicyEvalResult::Combined {
policy_type,
operation,
children,
outcome,
} => {
assert_eq!(operation, CombineOp::Not);
assert!(!outcome);
assert_eq!(children.len(), 1);
assert!(children[0].format(0).contains("AlwaysAllowPolicy"));
assert_eq!(policy_type, "NotPolicy");
}
_ => panic!("Expected Combined result from NotPolicy, got {:?}", result),
}
}
#[tokio::test]
async fn test_empty_policies_in_combinators() {
let and_policy_result =
AndPolicy::<TestSubject, TestResource, TestAction, TestContext>::try_new(vec![]);
assert!(and_policy_result.is_err());
let or_policy_result =
OrPolicy::<TestSubject, TestResource, TestAction, TestContext>::try_new(vec![]);
assert!(or_policy_result.is_err());
}
#[tokio::test]
async fn test_deeply_nested_combinators() {
let inner_not = NotPolicy::new(AlwaysDenyPolicy("InnerDeny"));
let inner_or = OrPolicy::try_new(vec![
Arc::new(AlwaysDenyPolicy("MidDeny")),
Arc::new(inner_not),
])
.expect("Unable to create or-policy policy");
let inner_and = AndPolicy::try_new(vec![Arc::new(AlwaysAllowPolicy), Arc::new(inner_or)])
.expect("Unable to create and-policy policy");
let outer_not = NotPolicy::new(inner_and);
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = outer_not
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(!result.is_granted());
let trace_str = result.format(0);
assert!(trace_str.contains("NOT"));
assert!(trace_str.contains("AND"));
assert!(trace_str.contains("OR"));
assert!(trace_str.contains("InnerDeny"));
}
#[derive(Debug, Clone)]
struct FeatureFlagContext {
feature_enabled: bool,
}
struct FeatureFlagPolicy;
#[async_trait]
impl Policy<TestSubject, TestResource, TestAction, FeatureFlagContext> for FeatureFlagPolicy {
async fn evaluate_access(
&self,
_subject: &TestSubject,
_action: &TestAction,
_resource: &TestResource,
context: &FeatureFlagContext,
) -> PolicyEvalResult {
if context.feature_enabled {
PolicyEvalResult::Granted {
policy_type: self.policy_type(),
reason: Some("Feature flag enabled".to_string()),
}
} else {
PolicyEvalResult::Denied {
policy_type: self.policy_type(),
reason: "Feature flag disabled".to_string(),
}
}
}
fn policy_type(&self) -> String {
"FeatureFlagPolicy".to_string()
}
}
#[tokio::test]
async fn test_context_sensitive_policy() {
let policy = FeatureFlagPolicy;
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let context_enabled = FeatureFlagContext {
feature_enabled: true,
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &context_enabled)
.await;
assert!(result.is_granted());
let context_disabled = FeatureFlagContext {
feature_enabled: false,
};
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &context_disabled)
.await;
assert!(!result.is_granted());
}
#[tokio::test]
async fn test_short_circuit_evaluation() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc as StdArc;
let evaluation_count = StdArc::new(AtomicUsize::new(0));
struct CountingPolicy {
result: bool,
counter: StdArc<AtomicUsize>,
}
#[async_trait]
impl Policy<TestSubject, TestResource, TestAction, TestContext> for CountingPolicy {
async fn evaluate_access(
&self,
_subject: &TestSubject,
_action: &TestAction,
_resource: &TestResource,
_context: &TestContext,
) -> PolicyEvalResult {
self.counter.fetch_add(1, Ordering::SeqCst);
if self.result {
PolicyEvalResult::Granted {
policy_type: self.policy_type(),
reason: Some("Counting policy granted".to_string()),
}
} else {
PolicyEvalResult::Denied {
policy_type: self.policy_type(),
reason: "Counting policy denied".to_string(),
}
}
}
fn policy_type(&self) -> String {
"CountingPolicy".to_string()
}
}
let count_clone = evaluation_count.clone();
evaluation_count.store(0, Ordering::SeqCst);
let and_policy = AndPolicy::try_new(vec![
Arc::new(CountingPolicy {
result: false,
counter: count_clone.clone(),
}),
Arc::new(CountingPolicy {
result: true,
counter: count_clone,
}),
])
.expect("Unable to create 'and' policy");
let subject = TestSubject {
id: uuid::Uuid::new_v4(),
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
and_policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert_eq!(
evaluation_count.load(Ordering::SeqCst),
1,
"AND policy should short-circuit after first deny"
);
let count_clone = evaluation_count.clone();
evaluation_count.store(0, Ordering::SeqCst);
let or_policy = OrPolicy::try_new(vec![
Arc::new(CountingPolicy {
result: true,
counter: count_clone.clone(),
}),
Arc::new(CountingPolicy {
result: false,
counter: count_clone,
}),
])
.unwrap();
or_policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert_eq!(
evaluation_count.load(Ordering::SeqCst),
1,
"OR policy should short-circuit after first allow"
);
}
}
#[cfg(test)]
mod policy_builder_tests {
use super::*;
use uuid::Uuid;
#[derive(Debug, Clone)]
struct TestSubject {
pub name: String,
}
#[derive(Debug, Clone)]
struct TestAction;
#[derive(Debug, Clone)]
struct TestResource;
#[derive(Debug, Clone)]
struct TestContext;
#[tokio::test]
async fn test_policy_builder_allows_when_no_predicates() {
let policy = PolicyBuilder::<TestSubject, TestResource, TestAction, TestContext>::new(
"NoPredicatesPolicy",
)
.build();
let result = policy
.evaluate_access(
&TestSubject { name: "Any".into() },
&TestAction,
&TestResource,
&TestContext,
)
.await;
assert!(
result.is_granted(),
"Policy built with no predicates should allow access (default true)"
);
}
#[tokio::test]
async fn test_policy_builder_with_subject_predicate() {
let policy = PolicyBuilder::<TestSubject, TestResource, TestAction, TestContext>::new(
"SubjectPolicy",
)
.subjects(|s: &TestSubject| s.name == "Alice")
.build();
let result1 = policy
.evaluate_access(
&TestSubject {
name: "Alice".into(),
},
&TestAction,
&TestResource,
&TestContext,
)
.await;
assert!(
result1.is_granted(),
"Policy should allow access for subject 'Alice'"
);
let result2 = policy
.evaluate_access(
&TestSubject { name: "Bob".into() },
&TestAction,
&TestResource,
&TestContext,
)
.await;
assert!(
!result2.is_granted(),
"Policy should deny access for subject not named 'Alice'"
);
}
#[tokio::test]
async fn test_policy_builder_effect_deny() {
let policy =
PolicyBuilder::<TestSubject, TestResource, TestAction, TestContext>::new("DenyPolicy")
.effect(Effect::Deny)
.build();
let result = policy
.evaluate_access(
&TestSubject {
name: "Anyone".into(),
},
&TestAction,
&TestResource,
&TestContext,
)
.await;
assert!(
!result.is_granted(),
"Policy with effect Deny should result in denial even if the predicate passes"
);
}
#[tokio::test]
async fn test_policy_builder_with_extra_condition() {
#[derive(Debug, Clone)]
struct ExtendedSubject {
pub id: Uuid,
pub name: String,
}
#[derive(Debug, Clone)]
struct ExtendedResource {
pub owner_id: Uuid,
}
#[derive(Debug, Clone)]
struct ExtendedAction;
#[derive(Debug, Clone)]
struct ExtendedContext;
let subject_id = Uuid::new_v4();
let policy = PolicyBuilder::<
ExtendedSubject,
ExtendedResource,
ExtendedAction,
ExtendedContext,
>::new("AliceOwnerPolicy")
.subjects(|s: &ExtendedSubject| s.name == "Alice")
.when(|s, _a, r, _c| s.id == r.owner_id)
.build();
let result1 = policy
.evaluate_access(
&ExtendedSubject {
id: subject_id,
name: "Alice".into(),
},
&ExtendedAction,
&ExtendedResource {
owner_id: subject_id,
},
&ExtendedContext,
)
.await;
assert!(
result1.is_granted(),
"Policy should allow access when conditions are met"
);
let result2 = policy
.evaluate_access(
&ExtendedSubject {
id: subject_id,
name: "Alice".into(),
},
&ExtendedAction,
&ExtendedResource {
owner_id: Uuid::new_v4(),
},
&ExtendedContext,
)
.await;
assert!(
!result2.is_granted(),
"Policy should deny access when extra condition fails"
);
}
}