#![warn(missing_docs)]
#![allow(clippy::type_complexity)]
use async_trait::async_trait;
use std::fmt;
use std::sync::Arc;
const DEFAULT_SECURITY_RULE_CATEGORY: &str = "Access Control";
const PERMISSION_CHECKER_POLICY_TYPE: &str = "PermissionChecker";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SecurityRuleMetadata {
name: Option<String>,
category: Option<String>,
description: Option<String>,
reference: Option<String>,
ruleset_name: Option<String>,
uuid: Option<String>,
version: Option<String>,
license: Option<String>,
}
impl SecurityRuleMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
self.reference = Some(reference.into());
self
}
pub fn with_ruleset_name(mut self, ruleset_name: impl Into<String>) -> Self {
self.ruleset_name = Some(ruleset_name.into());
self
}
pub fn with_uuid(mut self, uuid: impl Into<String>) -> Self {
self.uuid = Some(uuid.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_license(mut self, license: impl Into<String>) -> Self {
self.license = Some(license.into());
self
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn category(&self) -> Option<&str> {
self.category.as_deref()
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn reference(&self) -> Option<&str> {
self.reference.as_deref()
}
pub fn ruleset_name(&self) -> Option<&str> {
self.ruleset_name.as_deref()
}
pub fn uuid(&self) -> Option<&str> {
self.uuid.as_deref()
}
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
pub fn license(&self) -> Option<&str> {
self.license.as_deref()
}
}
#[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;
fn security_rule(&self) -> SecurityRuleMetadata {
SecurityRuleMetadata::default()
}
}
#[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: PERMISSION_CHECKER_POLICY_TYPE.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::with_capacity(self.policies.len());
for policy in &self.policies {
let result = policy
.evaluate_access(subject, action, resource, context)
.await;
let result_passes = result.is_granted();
let policy_type = policy.policy_type();
let policy_type_str = policy_type.as_str();
let metadata = policy.security_rule();
let reason = result.reason();
let reason_str = reason.as_deref();
let rule_name = metadata.name().unwrap_or(policy_type_str);
let category = metadata
.category()
.unwrap_or(DEFAULT_SECURITY_RULE_CATEGORY);
let ruleset_name = metadata
.ruleset_name()
.unwrap_or(PERMISSION_CHECKER_POLICY_TYPE);
let event_outcome = if result_passes { "success" } else { "failure" };
tracing::trace!(
target: "gatehouse::security",
{
security_rule.name = rule_name,
security_rule.category = category,
security_rule.description = metadata.description(),
security_rule.reference = metadata.reference(),
security_rule.ruleset.name = ruleset_name,
security_rule.uuid = metadata.uuid(),
security_rule.version = metadata.version(),
security_rule.license = metadata.license(),
event.outcome = event_outcome,
policy.type = policy_type_str,
policy.result.reason = reason_str,
},
"Security rule evaluated"
);
policy_results.push(result);
if result_passes {
let combined = PolicyEvalResult::Combined {
policy_type: PERMISSION_CHECKER_POLICY_TYPE.to_string(),
operation: CombineOp::Or,
children: policy_results,
outcome: true,
};
return AccessEvaluation::Granted {
policy_type,
reason,
trace: EvalTrace::with_root(combined),
};
}
}
tracing::trace!("No policies allowed access, returning Forbidden");
let combined = PolicyEvalResult::Combined {
policy_type: PERMISSION_CHECKER_POLICY_TYPE.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()
}
fn security_rule(&self) -> SecurityRuleMetadata {
(**self).security_rule()
}
}
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, Re>: Send + Sync {
async fn has_relationship(&self, subject: &S, resource: &R, relationship: &Re) -> bool;
}
pub struct RebacPolicy<S, R, A, C, Re, RG> {
pub relationship: Re,
pub resolver: RG,
_marker: std::marker::PhantomData<(S, R, A, C)>,
}
impl<S, R, A, C, Re, RG> RebacPolicy<S, R, A, C, Re, RG> {
pub fn new(relationship: Re, resolver: RG) -> Self {
Self {
relationship,
resolver,
_marker: std::marker::PhantomData,
}
}
}
#[async_trait]
impl<S, R, A, C, Re, RG> Policy<S, R, A, C> for RebacPolicy<S, R, A, C, Re, RG>
where
S: Sync + Send,
R: Sync + Send,
A: Sync + Send,
C: Sync + Send,
Re: Sync + Send + fmt::Display,
RG: RelationshipResolver<S, R, Re>,
{
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::with_capacity(self.policies.len());
for policy in &self.policies {
let result = policy
.evaluate_access(subject, action, resource, context)
.await;
let is_granted = result.is_granted();
children_results.push(result);
if !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::with_capacity(self.policies.len());
for policy in &self.policies {
let result = policy
.evaluate_access(subject, action, resource, context)
.await;
let is_granted = result.is_granted();
children_results.push(result);
if 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;
let is_granted = inner_result.is_granted();
PolicyEvalResult::Combined {
policy_type: Policy::<S, R, A, C>::policy_type(self),
operation: CombineOp::Not,
children: vec![inner_result],
outcome: !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;
#[test]
fn security_rule_metadata_builder_sets_fields() {
let metadata = SecurityRuleMetadata::new()
.with_name("Example")
.with_category("Access Control")
.with_description("Example description")
.with_reference("https://example.com/rule")
.with_ruleset_name("ExampleRuleset")
.with_uuid("1234")
.with_version("1.0.0")
.with_license("Apache-2.0");
assert_eq!(metadata.name(), Some("Example"));
assert_eq!(metadata.category(), Some("Access Control"));
assert_eq!(metadata.description(), Some("Example description"));
assert_eq!(metadata.reference(), Some("https://example.com/rule"));
assert_eq!(metadata.ruleset_name(), Some("ExampleRuleset"));
assert_eq!(metadata.uuid(), Some("1234"));
assert_eq!(metadata.version(), Some("1.0.0"));
assert_eq!(metadata.license(), Some("Apache-2.0"));
}
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, String> for DummyRelationshipResolver {
async fn has_relationship(
&self,
subject: &TestSubject,
resource: &TestResource,
relationship: &String,
) -> 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".to_string();
let subject = TestSubject { id: subject_id };
let resource = TestResource { id: resource_id };
let resolver =
DummyRelationshipResolver::new(vec![(subject_id, resource_id, relationship.clone())]);
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".to_string();
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"
);
}
#[derive(Debug, Clone, PartialEq)]
enum TestRelation {
Manager,
Viewer,
}
impl fmt::Display for TestRelation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TestRelation::Manager => write!(f, "manager"),
TestRelation::Viewer => write!(f, "viewer"),
}
}
}
struct EnumRelationshipResolver {
relationships: Vec<(uuid::Uuid, uuid::Uuid, TestRelation)>,
}
#[async_trait]
impl RelationshipResolver<TestSubject, TestResource, TestRelation> for EnumRelationshipResolver {
async fn has_relationship(
&self,
subject: &TestSubject,
resource: &TestResource,
relationship: &TestRelation,
) -> bool {
self.relationships
.iter()
.any(|(s, r, rel)| s == &subject.id && r == &resource.id && rel == relationship)
}
}
#[tokio::test]
async fn test_rebac_policy_with_enum_relationship() {
let subject_id = uuid::Uuid::new_v4();
let resource_id = uuid::Uuid::new_v4();
let subject = TestSubject { id: subject_id };
let resource = TestResource { id: resource_id };
let resolver = EnumRelationshipResolver {
relationships: vec![(subject_id, resource_id, TestRelation::Manager)],
};
let policy = RebacPolicy::<TestSubject, TestResource, TestAction, TestContext, _, _>::new(
TestRelation::Manager,
resolver,
);
let result = policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
result.is_granted(),
"Access should be granted for matching enum relationship"
);
let resolver = EnumRelationshipResolver {
relationships: vec![(subject_id, resource_id, TestRelation::Manager)],
};
let viewer_policy =
RebacPolicy::<TestSubject, TestResource, TestAction, TestContext, _, _>::new(
TestRelation::Viewer,
resolver,
);
let result = viewer_policy
.evaluate_access(&subject, &TestAction, &resource, &TestContext)
.await;
assert!(
!result.is_granted(),
"Access should be denied when enum relationship does not match"
);
}
#[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_abac_policy_grants_when_condition_true() {
let policy = AbacPolicy::new(
|_subject: &TestSubject, _resource: &TestResource, _action: &TestAction, _context: &TestContext| {
true
},
);
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(), "AbacPolicy should grant when condition returns true");
assert_eq!(policy.policy_type(), "AbacPolicy");
}
#[tokio::test]
async fn test_abac_policy_denies_when_condition_false() {
let policy = AbacPolicy::new(
|_subject: &TestSubject, _resource: &TestResource, _action: &TestAction, _context: &TestContext| {
false
},
);
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(), "AbacPolicy should deny when condition returns false");
match result {
PolicyEvalResult::Denied { policy_type, reason } => {
assert_eq!(policy_type, "AbacPolicy");
assert!(reason.contains("false"));
}
_ => panic!("Expected Denied result, got {:?}", result),
}
}
#[tokio::test]
async fn test_abac_policy_with_attribute_check() {
let policy = AbacPolicy::new(
|subject: &TestSubject, resource: &TestResource, _action: &TestAction, _context: &TestContext| {
subject.id == resource.id
},
);
let owner_id = uuid::Uuid::new_v4();
let owner = TestSubject { id: owner_id };
let owned_resource = TestResource { id: owner_id };
let other_resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result = policy
.evaluate_access(&owner, &TestAction, &owned_resource, &TestContext)
.await;
assert!(result.is_granted(), "Owner should have access to owned resource");
let result = policy
.evaluate_access(&owner, &TestAction, &other_resource, &TestContext)
.await;
assert!(!result.is_granted(), "Owner should not have access to other resource");
}
#[tokio::test]
async fn test_rbac_policy_grants_when_user_has_required_role() {
let admin_role = uuid::Uuid::new_v4();
let user_role = uuid::Uuid::new_v4();
#[derive(Debug, Clone)]
struct RbacUser {
roles: Vec<uuid::Uuid>,
}
let policy = RbacPolicy::new(
|_resource: &TestResource, _action: &TestAction| vec![admin_role],
|subject: &RbacUser| subject.roles.clone(),
);
let admin_user = RbacUser {
roles: vec![admin_role, user_role],
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result: PolicyEvalResult = Policy::<RbacUser, TestResource, TestAction, TestContext>::evaluate_access(
&policy,
&admin_user,
&TestAction,
&resource,
&TestContext,
)
.await;
assert!(result.is_granted(), "User with required role should be granted access");
assert_eq!(
Policy::<RbacUser, TestResource, TestAction, TestContext>::policy_type(&policy),
"RbacPolicy"
);
}
#[tokio::test]
async fn test_rbac_policy_denies_when_user_lacks_required_role() {
let admin_role = uuid::Uuid::new_v4();
let user_role = uuid::Uuid::new_v4();
#[derive(Debug, Clone)]
struct RbacUser {
roles: Vec<uuid::Uuid>,
}
let policy = RbacPolicy::new(
|_resource: &TestResource, _action: &TestAction| vec![admin_role],
|subject: &RbacUser| subject.roles.clone(),
);
let regular_user = RbacUser {
roles: vec![user_role],
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result: PolicyEvalResult = Policy::<RbacUser, TestResource, TestAction, TestContext>::evaluate_access(
&policy,
®ular_user,
&TestAction,
&resource,
&TestContext,
)
.await;
assert!(!result.is_granted(), "User without required role should be denied");
match result {
PolicyEvalResult::Denied { policy_type, reason } => {
assert_eq!(policy_type, "RbacPolicy");
assert!(reason.contains("doesn't have required role"));
}
_ => panic!("Expected Denied result, got {:?}", result),
}
}
#[tokio::test]
async fn test_rbac_policy_grants_with_any_matching_role() {
let role1 = uuid::Uuid::new_v4();
let role2 = uuid::Uuid::new_v4();
let role3 = uuid::Uuid::new_v4();
#[derive(Debug, Clone)]
struct RbacUser {
roles: Vec<uuid::Uuid>,
}
let policy = RbacPolicy::new(
|_resource: &TestResource, _action: &TestAction| vec![role1, role2],
|subject: &RbacUser| subject.roles.clone(),
);
let user = RbacUser {
roles: vec![role2, role3],
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result: PolicyEvalResult = Policy::<RbacUser, TestResource, TestAction, TestContext>::evaluate_access(
&policy,
&user,
&TestAction,
&resource,
&TestContext,
)
.await;
assert!(result.is_granted(), "User with any required role should be granted access");
}
#[tokio::test]
async fn test_rbac_policy_denies_with_empty_user_roles() {
let admin_role = uuid::Uuid::new_v4();
#[derive(Debug, Clone)]
struct RbacUser {
roles: Vec<uuid::Uuid>,
}
let policy = RbacPolicy::new(
|_resource: &TestResource, _action: &TestAction| vec![admin_role],
|subject: &RbacUser| subject.roles.clone(),
);
let user_no_roles = RbacUser { roles: vec![] };
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result: PolicyEvalResult = Policy::<RbacUser, TestResource, TestAction, TestContext>::evaluate_access(
&policy,
&user_no_roles,
&TestAction,
&resource,
&TestContext,
)
.await;
assert!(!result.is_granted(), "User with no roles should be denied");
}
#[tokio::test]
async fn test_rbac_policy_denies_with_empty_required_roles() {
let user_role = uuid::Uuid::new_v4();
#[derive(Debug, Clone)]
struct RbacUser {
roles: Vec<uuid::Uuid>,
}
let policy = RbacPolicy::new(
|_resource: &TestResource, _action: &TestAction| vec![],
|subject: &RbacUser| subject.roles.clone(),
);
let user = RbacUser {
roles: vec![user_role],
};
let resource = TestResource {
id: uuid::Uuid::new_v4(),
};
let result: PolicyEvalResult = Policy::<RbacUser, TestResource, TestAction, TestContext>::evaluate_access(
&policy,
&user,
&TestAction,
&resource,
&TestContext,
)
.await;
assert!(!result.is_granted(), "Empty required roles means no match is possible");
}
#[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"
);
}
#[tokio::test]
async fn test_access_evaluation_to_result_granted() {
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;
let converted: Result<(), String> = result.to_result(|reason| reason.to_string());
assert!(converted.is_ok(), "to_result should return Ok for granted access");
}
#[tokio::test]
async fn test_access_evaluation_to_result_denied() {
let mut checker = PermissionChecker::new();
checker.add_policy(AlwaysDenyPolicy("Access denied"));
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;
let converted: Result<(), String> = result.to_result(|reason| reason.to_string());
assert!(converted.is_err(), "to_result should return Err for denied access");
assert!(converted.unwrap_err().contains("denied"));
}
#[tokio::test]
async fn test_access_evaluation_display_trace_granted() {
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;
let trace_display = result.display_trace();
assert!(trace_display.contains("GRANTED"), "Trace should show GRANTED");
assert!(trace_display.contains("AlwaysAllowPolicy"), "Trace should show policy name");
assert!(trace_display.contains("Evaluation Trace"), "Trace should include trace section");
}
#[tokio::test]
async fn test_access_evaluation_display_trace_denied() {
let mut checker = PermissionChecker::new();
checker.add_policy(AlwaysDenyPolicy("Test denial"));
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;
let trace_display = result.display_trace();
assert!(trace_display.contains("Denied"), "Trace should show Denied");
assert!(trace_display.contains("Test denial"), "Trace should show denial reason");
}
#[tokio::test]
async fn test_access_evaluation_display_impl() {
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;
let display_str = format!("{}", result);
assert!(display_str.contains("GRANTED"), "Display should show GRANTED");
assert!(display_str.contains("AlwaysAllowPolicy"), "Display should show policy name");
}
#[test]
fn test_eval_trace_new_creates_empty() {
let trace = EvalTrace::new();
assert!(trace.root().is_none(), "New trace should have no root");
assert_eq!(
trace.format(),
"No evaluation trace available",
"Empty trace should format as 'No evaluation trace available'"
);
}
#[test]
fn test_eval_trace_with_root() {
let result = PolicyEvalResult::Granted {
policy_type: "TestPolicy".to_string(),
reason: Some("Test reason".to_string()),
};
let trace = EvalTrace::with_root(result);
assert!(trace.root().is_some(), "Trace with root should have a root");
let formatted = trace.format();
assert!(formatted.contains("TestPolicy"), "Formatted trace should contain policy name");
assert!(formatted.contains("GRANTED"), "Formatted trace should contain GRANTED");
}
#[test]
fn test_eval_trace_set_root() {
let mut trace = EvalTrace::new();
assert!(trace.root().is_none());
let result = PolicyEvalResult::Denied {
policy_type: "DenyPolicy".to_string(),
reason: "Denied for testing".to_string(),
};
trace.set_root(result);
assert!(trace.root().is_some(), "After set_root, trace should have a root");
let formatted = trace.format();
assert!(formatted.contains("DenyPolicy"));
assert!(formatted.contains("DENIED"));
}
#[test]
fn test_eval_trace_default() {
let trace = EvalTrace::default();
assert!(trace.root().is_none(), "Default trace should have no root");
}
#[test]
fn test_policy_eval_result_reason_granted() {
let result = PolicyEvalResult::Granted {
policy_type: "TestPolicy".to_string(),
reason: Some("Grant reason".to_string()),
};
assert_eq!(result.reason(), Some("Grant reason".to_string()));
let result_no_reason = PolicyEvalResult::Granted {
policy_type: "TestPolicy".to_string(),
reason: None,
};
assert_eq!(result_no_reason.reason(), None);
}
#[test]
fn test_policy_eval_result_reason_denied() {
let result = PolicyEvalResult::Denied {
policy_type: "TestPolicy".to_string(),
reason: "Deny reason".to_string(),
};
assert_eq!(result.reason(), Some("Deny reason".to_string()));
}
#[test]
fn test_policy_eval_result_reason_combined() {
let result = PolicyEvalResult::Combined {
policy_type: "CombinedPolicy".to_string(),
operation: CombineOp::And,
children: vec![],
outcome: true,
};
assert_eq!(result.reason(), None, "Combined result should have no reason");
}
#[test]
fn test_policy_eval_result_format_indentation() {
let result = PolicyEvalResult::Granted {
policy_type: "TestPolicy".to_string(),
reason: Some("Test".to_string()),
};
let formatted_0 = result.format(0);
let formatted_4 = result.format(4);
assert!(formatted_0.starts_with("✔"), "Indent 0 should start with checkmark");
assert!(formatted_4.starts_with(" ✔"), "Indent 4 should have 4 spaces before checkmark");
}
#[test]
fn test_policy_eval_result_display() {
let result = PolicyEvalResult::Denied {
policy_type: "TestPolicy".to_string(),
reason: "Test denial".to_string(),
};
let display_str = format!("{}", result);
assert!(display_str.contains("TestPolicy"));
assert!(display_str.contains("DENIED"));
assert!(display_str.contains("Test denial"));
}
#[test]
fn test_combine_op_display() {
assert_eq!(format!("{}", CombineOp::And), "AND");
assert_eq!(format!("{}", CombineOp::Or), "OR");
assert_eq!(format!("{}", CombineOp::Not), "NOT");
}
#[tokio::test]
async fn test_permission_checker_default() {
let checker =
PermissionChecker::<TestSubject, TestResource, TestAction, TestContext>::default();
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(), "Default checker with no policies should deny");
}
#[test]
fn test_security_rule_metadata_default_values() {
let metadata = SecurityRuleMetadata::default();
assert_eq!(metadata.name(), None);
assert_eq!(metadata.category(), None);
assert_eq!(metadata.description(), None);
assert_eq!(metadata.reference(), None);
assert_eq!(metadata.ruleset_name(), None);
assert_eq!(metadata.uuid(), None);
assert_eq!(metadata.version(), None);
assert_eq!(metadata.license(), None);
}
#[test]
fn test_security_rule_metadata_new_equals_default() {
let new_metadata = SecurityRuleMetadata::new();
let default_metadata = SecurityRuleMetadata::default();
assert_eq!(new_metadata, default_metadata);
}
#[test]
fn test_security_rule_metadata_partial_builder() {
let metadata = SecurityRuleMetadata::new()
.with_name("TestRule")
.with_category("TestCategory");
assert_eq!(metadata.name(), Some("TestRule"));
assert_eq!(metadata.category(), Some("TestCategory"));
assert_eq!(metadata.description(), None);
assert_eq!(metadata.reference(), None);
}
#[tokio::test]
async fn test_policy_default_security_rule() {
let policy = AlwaysAllowPolicy;
let metadata = Policy::<TestSubject, TestResource, TestAction, TestContext>::security_rule(&policy);
assert_eq!(metadata, SecurityRuleMetadata::default());
}
#[test]
fn test_empty_policies_error_debug() {
let error = EmptyPoliciesError("Test error message");
let debug_str = format!("{:?}", error);
assert!(debug_str.contains("Test error message"));
}
#[test]
#[allow(clippy::clone_on_copy)] fn test_empty_policies_error_copy_clone() {
let error = EmptyPoliciesError("Test");
let copied = error;
let cloned = error.clone();
assert_eq!(copied.0, "Test");
assert_eq!(cloned.0, "Test");
}
}
#[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"
);
}
#[tokio::test]
async fn test_policy_builder_with_action_predicate() {
#[derive(Debug, Clone)]
struct ActionType {
pub name: String,
}
let policy =
PolicyBuilder::<TestSubject, TestResource, ActionType, TestContext>::new("ActionPolicy")
.actions(|a: &ActionType| a.name == "read")
.build();
let result = policy
.evaluate_access(
&TestSubject { name: "Anyone".into() },
&ActionType { name: "read".into() },
&TestResource,
&TestContext,
)
.await;
assert!(result.is_granted(), "Policy should allow 'read' action");
let result = policy
.evaluate_access(
&TestSubject { name: "Anyone".into() },
&ActionType { name: "write".into() },
&TestResource,
&TestContext,
)
.await;
assert!(!result.is_granted(), "Policy should deny 'write' action");
}
#[tokio::test]
async fn test_policy_builder_with_resource_predicate() {
#[derive(Debug, Clone)]
struct ResourceType {
pub public: bool,
}
let policy = PolicyBuilder::<TestSubject, ResourceType, TestAction, TestContext>::new(
"ResourcePolicy",
)
.resources(|r: &ResourceType| r.public)
.build();
let result = policy
.evaluate_access(
&TestSubject { name: "Anyone".into() },
&TestAction,
&ResourceType { public: true },
&TestContext,
)
.await;
assert!(result.is_granted(), "Policy should allow public resource");
let result = policy
.evaluate_access(
&TestSubject { name: "Anyone".into() },
&TestAction,
&ResourceType { public: false },
&TestContext,
)
.await;
assert!(!result.is_granted(), "Policy should deny private resource");
}
#[tokio::test]
async fn test_policy_builder_with_context_predicate() {
#[derive(Debug, Clone)]
struct RequestContext {
pub is_internal: bool,
}
let policy = PolicyBuilder::<TestSubject, TestResource, TestAction, RequestContext>::new(
"ContextPolicy",
)
.context(|c: &RequestContext| c.is_internal)
.build();
let result = policy
.evaluate_access(
&TestSubject { name: "Anyone".into() },
&TestAction,
&TestResource,
&RequestContext { is_internal: true },
)
.await;
assert!(result.is_granted(), "Policy should allow internal requests");
let result = policy
.evaluate_access(
&TestSubject { name: "Anyone".into() },
&TestAction,
&TestResource,
&RequestContext { is_internal: false },
)
.await;
assert!(
!result.is_granted(),
"Policy should deny external requests"
);
}
#[tokio::test]
async fn test_policy_builder_with_all_predicates_combined() {
#[derive(Debug, Clone)]
struct FullSubject {
pub role: String,
}
#[derive(Debug, Clone)]
struct FullAction {
pub name: String,
}
#[derive(Debug, Clone)]
struct FullResource {
pub category: String,
}
#[derive(Debug, Clone)]
struct FullContext {
pub time_of_day: String,
}
let policy =
PolicyBuilder::<FullSubject, FullResource, FullAction, FullContext>::new("FullPolicy")
.subjects(|s: &FullSubject| s.role == "admin")
.actions(|a: &FullAction| a.name == "read")
.resources(|r: &FullResource| r.category == "document")
.context(|c: &FullContext| c.time_of_day == "business_hours")
.build();
let result = policy
.evaluate_access(
&FullSubject {
role: "admin".into(),
},
&FullAction { name: "read".into() },
&FullResource {
category: "document".into(),
},
&FullContext {
time_of_day: "business_hours".into(),
},
)
.await;
assert!(
result.is_granted(),
"Policy should allow when all conditions are met"
);
let result = policy
.evaluate_access(
&FullSubject {
role: "user".into(),
},
&FullAction { name: "read".into() },
&FullResource {
category: "document".into(),
},
&FullContext {
time_of_day: "business_hours".into(),
},
)
.await;
assert!(!result.is_granted(), "Policy should deny wrong role");
let result = policy
.evaluate_access(
&FullSubject {
role: "admin".into(),
},
&FullAction {
name: "write".into(),
},
&FullResource {
category: "document".into(),
},
&FullContext {
time_of_day: "business_hours".into(),
},
)
.await;
assert!(!result.is_granted(), "Policy should deny wrong action");
let result = policy
.evaluate_access(
&FullSubject {
role: "admin".into(),
},
&FullAction { name: "read".into() },
&FullResource {
category: "video".into(),
},
&FullContext {
time_of_day: "business_hours".into(),
},
)
.await;
assert!(!result.is_granted(), "Policy should deny wrong resource");
let result = policy
.evaluate_access(
&FullSubject {
role: "admin".into(),
},
&FullAction { name: "read".into() },
&FullResource {
category: "document".into(),
},
&FullContext {
time_of_day: "after_hours".into(),
},
)
.await;
assert!(!result.is_granted(), "Policy should deny wrong context");
}
}