use aspect_core::{Aspect, AspectError, JoinPoint, ProceedingJoinPoint};
use parking_lot::RwLock;
use std::any::Any;
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Clone)]
pub struct AuthorizationAspect {
required_roles: Arc<HashSet<String>>,
role_provider: Arc<dyn Fn() -> HashSet<String> + Send + Sync>,
mode: AuthMode,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AuthMode {
RequireAll,
RequireAny,
}
impl AuthorizationAspect {
pub fn require_role<F>(role: &str, role_provider: F) -> Self
where
F: Fn() -> HashSet<String> + Send + Sync + 'static,
{
let mut roles = HashSet::new();
roles.insert(role.to_string());
Self {
required_roles: Arc::new(roles),
role_provider: Arc::new(role_provider),
mode: AuthMode::RequireAll,
}
}
pub fn require_roles<F>(roles: &[&str], role_provider: F, mode: AuthMode) -> Self
where
F: Fn() -> HashSet<String> + Send + Sync + 'static,
{
let role_set: HashSet<String> = roles.iter().map(|r| r.to_string()).collect();
Self {
required_roles: Arc::new(role_set),
role_provider: Arc::new(role_provider),
mode,
}
}
fn check_authorization(&self) -> Result<(), String> {
let current_roles = (self.role_provider)();
let authorized = match self.mode {
AuthMode::RequireAll => {
self.required_roles.iter().all(|r| current_roles.contains(r))
}
AuthMode::RequireAny => {
self.required_roles.iter().any(|r| current_roles.contains(r))
}
};
if authorized {
Ok(())
} else {
let required: Vec<_> = self.required_roles.iter().cloned().collect();
let mode_str = match self.mode {
AuthMode::RequireAll => "all",
AuthMode::RequireAny => "any",
};
Err(format!(
"Access denied: requires {} of roles {:?}",
mode_str, required
))
}
}
}
impl Aspect for AuthorizationAspect {
fn before(&self, ctx: &JoinPoint) {
if let Err(msg) = self.check_authorization() {
panic!("Authorization failed for {}: {}", ctx.function_name, msg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mock_roles(roles: Vec<&str>) -> HashSet<String> {
roles.into_iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_require_role_success() {
let auth = AuthorizationAspect::require_role("admin", || mock_roles(vec!["admin"]));
assert!(auth.check_authorization().is_ok());
}
#[test]
fn test_require_role_failure() {
let auth = AuthorizationAspect::require_role("admin", || mock_roles(vec!["user"]));
assert!(auth.check_authorization().is_err());
}
#[test]
fn test_require_all_success() {
let auth = AuthorizationAspect::require_roles(
&["admin", "moderator"],
|| mock_roles(vec!["admin", "moderator", "user"]),
AuthMode::RequireAll,
);
assert!(auth.check_authorization().is_ok());
}
#[test]
fn test_require_all_failure() {
let auth = AuthorizationAspect::require_roles(
&["admin", "moderator"],
|| mock_roles(vec!["admin"]),
AuthMode::RequireAll,
);
assert!(auth.check_authorization().is_err());
}
#[test]
fn test_require_any_success() {
let auth = AuthorizationAspect::require_roles(
&["admin", "moderator"],
|| mock_roles(vec!["moderator"]),
AuthMode::RequireAny,
);
assert!(auth.check_authorization().is_ok());
}
#[test]
fn test_require_any_failure() {
let auth = AuthorizationAspect::require_roles(
&["admin", "moderator"],
|| mock_roles(vec!["user"]),
AuthMode::RequireAny,
);
assert!(auth.check_authorization().is_err());
}
#[test]
fn test_empty_roles() {
let auth = AuthorizationAspect::require_role("admin", || mock_roles(vec![]));
assert!(auth.check_authorization().is_err());
}
#[test]
fn test_multiple_roles_user() {
let auth = AuthorizationAspect::require_role("admin", || {
mock_roles(vec!["user", "moderator", "admin"])
});
assert!(auth.check_authorization().is_ok());
}
}
#[derive(Clone)]
pub struct AllowlistAspect {
inner: Arc<AllowlistInner>,
}
struct AllowlistInner {
entries: RwLock<Vec<String>>,
normalizer: Option<Box<dyn Fn(&str) -> String + Send + Sync>>,
matcher: Option<Box<dyn Fn(&str, &str) -> bool + Send + Sync>>,
}
impl AllowlistAspect {
pub fn new<I, S>(entries: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
inner: Arc::new(AllowlistInner {
entries: RwLock::new(entries.into_iter().map(Into::into).collect()),
normalizer: None,
matcher: None,
}),
}
}
pub fn with_normalizer<F>(mut self, normalizer: F) -> Self
where
F: Fn(&str) -> String + Send + Sync + 'static,
{
let inner = Arc::make_mut(&mut self.inner);
inner.normalizer = Some(Box::new(normalizer));
self
}
pub fn with_matcher<F>(mut self, matcher: F) -> Self
where
F: Fn(&str, &str) -> bool + Send + Sync + 'static,
{
let inner = Arc::make_mut(&mut self.inner);
inner.matcher = Some(Box::new(matcher));
self
}
pub fn is_allowed(&self, identity: &str) -> bool {
let entries = self.inner.entries.read();
if entries.is_empty() {
return false;
}
if let Some(matcher) = self.inner.matcher.as_ref() {
for entry in entries.iter() {
if entry == "*" {
return true;
}
if matcher(entry, identity) {
return true;
}
}
return false;
}
let norm = self.inner.normalizer.as_ref();
let needle = norm.map(|f| f(identity));
for entry in entries.iter() {
if entry == "*" {
return true;
}
match (&needle, norm) {
(Some(needle), Some(f)) => {
if f(entry) == *needle {
return true;
}
}
_ => {
if entry == identity {
return true;
}
}
}
}
false
}
pub fn add(&self, identity: impl Into<String>) {
let identity = identity.into();
let mut entries = self.inner.entries.write();
if !entries.iter().any(|e| e == &identity) {
entries.push(identity);
}
}
pub fn set<I, S>(&self, entries: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
*self.inner.entries.write() = entries.into_iter().map(Into::into).collect();
}
pub fn snapshot(&self) -> Vec<String> {
self.inner.entries.read().clone()
}
pub fn len(&self) -> usize {
self.inner.entries.read().len()
}
pub fn is_empty(&self) -> bool {
self.inner.entries.read().is_empty()
}
}
impl Clone for AllowlistInner {
fn clone(&self) -> Self {
Self {
entries: RwLock::new(self.entries.read().clone()),
normalizer: None,
matcher: None,
}
}
}
#[derive(Clone, Debug)]
pub struct AllowlistCall {
pub identity: String,
}
impl Aspect for AllowlistAspect {
fn before(&self, _ctx: &JoinPoint) {
}
fn around(&self, pjp: ProceedingJoinPoint) -> Result<Box<dyn Any>, AspectError> {
pjp.proceed()
}
}
#[cfg(test)]
mod allowlist_tests {
use super::*;
#[test]
fn empty_list_denies_everyone() {
let policy = AllowlistAspect::new(Vec::<String>::new());
assert!(!policy.is_allowed("alice"));
assert!(!policy.is_allowed(""));
}
#[test]
fn wildcard_admits_everyone() {
let policy = AllowlistAspect::new(["*".to_string()]);
assert!(policy.is_allowed("alice"));
assert!(policy.is_allowed("anyone"));
assert!(policy.is_allowed(""));
}
#[test]
fn specific_identity() {
let policy = AllowlistAspect::new(["alice".to_string(), "bob".to_string()]);
assert!(policy.is_allowed("alice"));
assert!(policy.is_allowed("bob"));
assert!(!policy.is_allowed("charlie"));
}
#[test]
fn wildcard_mixed_with_specific() {
let policy = AllowlistAspect::new(["alice".to_string(), "*".to_string()]);
assert!(policy.is_allowed("alice"));
assert!(policy.is_allowed("charlie"));
}
#[test]
fn normalizer_case_insensitive() {
let policy = AllowlistAspect::new(["Alice@Example.com".to_string()])
.with_normalizer(|s| s.to_ascii_lowercase());
assert!(policy.is_allowed("alice@example.com"));
assert!(policy.is_allowed("ALICE@EXAMPLE.COM"));
assert!(!policy.is_allowed("bob@example.com"));
}
#[test]
fn normalizer_phone_strip() {
let policy =
AllowlistAspect::new(["+1-555-0100".to_string()]).with_normalizer(|s| {
let mut out = String::new();
let mut chars = s.chars();
if let Some('+') = chars.clone().next() {
out.push('+');
chars.next();
}
for c in chars {
if c.is_ascii_digit() {
out.push(c);
}
}
out
});
assert!(policy.is_allowed("+15550100"));
assert!(policy.is_allowed("+1 555 0100"));
assert!(!policy.is_allowed("+15550101"));
}
#[test]
fn add_appends_unique() {
let policy = AllowlistAspect::new(["alice".to_string()]);
policy.add("bob");
policy.add("bob"); assert_eq!(policy.len(), 2);
assert!(policy.is_allowed("bob"));
}
#[test]
fn set_replaces_atomically() {
let policy = AllowlistAspect::new(["alice".to_string()]);
policy.set(["bob".to_string(), "carol".to_string()]);
assert!(!policy.is_allowed("alice"));
assert!(policy.is_allowed("bob"));
assert!(policy.is_allowed("carol"));
assert_eq!(policy.len(), 2);
}
#[test]
fn snapshot_returns_owned_copy() {
let policy = AllowlistAspect::new(["alice".to_string(), "bob".to_string()]);
let snap = policy.snapshot();
assert_eq!(snap, vec!["alice".to_string(), "bob".to_string()]);
policy.add("carol");
assert_eq!(snap.len(), 2);
}
#[test]
fn clone_shares_underlying_list() {
let policy_a = AllowlistAspect::new(["alice".to_string()]);
let policy_b = policy_a.clone();
policy_b.add("bob");
assert!(policy_a.is_allowed("bob"));
}
#[test]
fn parity_with_zeroclaw_archetype_a() {
let cases: Vec<(Vec<&str>, &str, bool)> = vec![
(vec![], "alice", false),
(vec![], "", false),
(vec!["*"], "alice", true),
(vec!["*"], "", true),
(vec!["alice"], "alice", true),
(vec!["alice"], "bob", false),
(vec!["alice", "bob"], "bob", true),
(vec!["alice", "*"], "anyone", true),
(vec!["*", "alice"], "anyone", true),
(vec!["alice"], "ALICE", false), (vec!["alice"], "alice ", false), ];
for (entries, identity, expected) in cases {
let policy = AllowlistAspect::new(entries.iter().map(|s| s.to_string()));
let baseline = entries.iter().any(|u| *u == "*" || *u == identity);
assert_eq!(
baseline, expected,
"baseline semantics drifted for entries={entries:?} identity={identity:?}"
);
assert_eq!(
policy.is_allowed(identity),
expected,
"AllowlistAspect mismatched zeroclaw archetype-A for entries={entries:?} identity={identity:?}"
);
}
}
fn email_domain_or_full_match(entry: &str, email: &str) -> bool {
let email_lower = email.to_ascii_lowercase();
if let Some(domain) = entry.strip_prefix('@') {
email_lower.ends_with(&format!("@{}", domain.to_ascii_lowercase()))
} else if entry.contains('@') {
entry.eq_ignore_ascii_case(email)
} else {
email_lower.ends_with(&format!("@{}", entry.to_ascii_lowercase()))
}
}
#[test]
fn matcher_email_domain_or_full() {
let policy = AllowlistAspect::new([
"alice@example.com".to_string(),
"@allowed.com".to_string(),
"bare.com".to_string(),
])
.with_matcher(email_domain_or_full_match);
assert!(policy.is_allowed("alice@example.com"));
assert!(policy.is_allowed("ALICE@Example.COM")); assert!(policy.is_allowed("anyone@allowed.com"));
assert!(policy.is_allowed("anyone@bare.com"));
assert!(!policy.is_allowed("bob@example.com")); assert!(!policy.is_allowed("anyone@other.com"));
}
#[test]
fn matcher_respects_wildcard_and_empty_shortcircuits() {
let empty =
AllowlistAspect::new(Vec::<String>::new()).with_matcher(|_e, _i| true);
assert!(!empty.is_allowed("anyone"));
let wild = AllowlistAspect::new(["*".to_string()])
.with_matcher(|_e, _i| panic!("matcher must not run when '*' present"));
assert!(wild.is_allowed("anyone"));
}
#[test]
fn matcher_takes_precedence_over_normalizer() {
let policy = AllowlistAspect::new(["Alice".to_string()])
.with_normalizer(|s| s.to_ascii_lowercase())
.with_matcher(|e, i| e == i);
assert!(policy.is_allowed("Alice"));
assert!(!policy.is_allowed("alice"));
}
}