aspect_std/authorization.rs
1//! Authorization aspects.
2//!
3//! This module provides two complementary authorization aspects:
4//!
5//! - [`AuthorizationAspect`] — role-based access control (RBAC). Given the
6//! set of roles the caller holds, admit or deny based on a required-role
7//! policy. The classical AspectJ / Spring Security shape.
8//!
9//! - [`AllowlistAspect`] — identity-based allowlisting. Given an identity
10//! string (user ID, phone number, email, public key, etc.), admit if it
11//! matches a configured set, with optional wildcard and normalization
12//! hooks. This is the shape that recurs across messaging-channel
13//! integrations: each channel has its own per-channel allowlist of who is
14//! permitted to talk to the bot. The ICSE 2027 evaluation migrates the
15//! zeroclaw codebase from 25 hand-rolled per-channel checks to a single
16//! shared `AllowlistAspect` configured per channel.
17
18use aspect_core::{Aspect, AspectError, JoinPoint, ProceedingJoinPoint};
19use parking_lot::RwLock;
20use std::any::Any;
21use std::collections::HashSet;
22use std::sync::Arc;
23
24/// Role-based access control aspect.
25///
26/// Enforces authorization checks before function execution based on
27/// required roles or permissions.
28///
29/// # Example
30///
31/// ```rust,ignore
32/// use aspect_std::AuthorizationAspect;
33/// use aspect_macros::aspect;
34///
35/// // Require "admin" role
36/// let auth = AuthorizationAspect::require_role("admin", || {
37/// get_current_user_roles()
38/// });
39///
40/// #[aspect(auth)]
41/// fn delete_user(user_id: u64) -> Result<(), String> {
42/// // Only admins can delete users
43/// Ok(())
44/// }
45/// ```
46#[derive(Clone)]
47pub struct AuthorizationAspect {
48 required_roles: Arc<HashSet<String>>,
49 role_provider: Arc<dyn Fn() -> HashSet<String> + Send + Sync>,
50 mode: AuthMode,
51}
52
53/// Authorization mode.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub enum AuthMode {
56 /// Require ALL specified roles.
57 RequireAll,
58 /// Require ANY of the specified roles.
59 RequireAny,
60}
61
62impl AuthorizationAspect {
63 /// Create an authorization aspect that requires a specific role.
64 ///
65 /// # Arguments
66 /// * `role` - The required role
67 /// * `role_provider` - Function that returns the current user's roles
68 ///
69 /// # Example
70 /// ```rust,ignore
71 /// let auth = AuthorizationAspect::require_role("admin", || {
72 /// vec!["admin".to_string()].into_iter().collect()
73 /// });
74 /// ```
75 pub fn require_role<F>(role: &str, role_provider: F) -> Self
76 where
77 F: Fn() -> HashSet<String> + Send + Sync + 'static,
78 {
79 let mut roles = HashSet::new();
80 roles.insert(role.to_string());
81
82 Self {
83 required_roles: Arc::new(roles),
84 role_provider: Arc::new(role_provider),
85 mode: AuthMode::RequireAll,
86 }
87 }
88
89 /// Create an authorization aspect that requires multiple roles.
90 ///
91 /// # Arguments
92 /// * `roles` - The required roles
93 /// * `role_provider` - Function that returns the current user's roles
94 /// * `mode` - Whether to require ALL or ANY of the roles
95 ///
96 /// # Example
97 /// ```rust,ignore
98 /// let auth = AuthorizationAspect::require_roles(
99 /// &["admin", "moderator"],
100 /// || get_current_roles(),
101 /// AuthMode::RequireAny
102 /// );
103 /// ```
104 pub fn require_roles<F>(roles: &[&str], role_provider: F, mode: AuthMode) -> Self
105 where
106 F: Fn() -> HashSet<String> + Send + Sync + 'static,
107 {
108 let role_set: HashSet<String> = roles.iter().map(|r| r.to_string()).collect();
109
110 Self {
111 required_roles: Arc::new(role_set),
112 role_provider: Arc::new(role_provider),
113 mode,
114 }
115 }
116
117 /// Check if the current user is authorized.
118 fn check_authorization(&self) -> Result<(), String> {
119 let current_roles = (self.role_provider)();
120
121 let authorized = match self.mode {
122 AuthMode::RequireAll => {
123 // User must have ALL required roles
124 self.required_roles.iter().all(|r| current_roles.contains(r))
125 }
126 AuthMode::RequireAny => {
127 // User must have ANY of the required roles
128 self.required_roles.iter().any(|r| current_roles.contains(r))
129 }
130 };
131
132 if authorized {
133 Ok(())
134 } else {
135 let required: Vec<_> = self.required_roles.iter().cloned().collect();
136 let mode_str = match self.mode {
137 AuthMode::RequireAll => "all",
138 AuthMode::RequireAny => "any",
139 };
140 Err(format!(
141 "Access denied: requires {} of roles {:?}",
142 mode_str, required
143 ))
144 }
145 }
146}
147
148impl Aspect for AuthorizationAspect {
149 fn before(&self, ctx: &JoinPoint) {
150 if let Err(msg) = self.check_authorization() {
151 panic!("Authorization failed for {}: {}", ctx.function_name, msg);
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn mock_roles(roles: Vec<&str>) -> HashSet<String> {
161 roles.into_iter().map(|s| s.to_string()).collect()
162 }
163
164 #[test]
165 fn test_require_role_success() {
166 let auth = AuthorizationAspect::require_role("admin", || mock_roles(vec!["admin"]));
167
168 assert!(auth.check_authorization().is_ok());
169 }
170
171 #[test]
172 fn test_require_role_failure() {
173 let auth = AuthorizationAspect::require_role("admin", || mock_roles(vec!["user"]));
174
175 assert!(auth.check_authorization().is_err());
176 }
177
178 #[test]
179 fn test_require_all_success() {
180 let auth = AuthorizationAspect::require_roles(
181 &["admin", "moderator"],
182 || mock_roles(vec!["admin", "moderator", "user"]),
183 AuthMode::RequireAll,
184 );
185
186 assert!(auth.check_authorization().is_ok());
187 }
188
189 #[test]
190 fn test_require_all_failure() {
191 let auth = AuthorizationAspect::require_roles(
192 &["admin", "moderator"],
193 || mock_roles(vec!["admin"]),
194 AuthMode::RequireAll,
195 );
196
197 assert!(auth.check_authorization().is_err());
198 }
199
200 #[test]
201 fn test_require_any_success() {
202 let auth = AuthorizationAspect::require_roles(
203 &["admin", "moderator"],
204 || mock_roles(vec!["moderator"]),
205 AuthMode::RequireAny,
206 );
207
208 assert!(auth.check_authorization().is_ok());
209 }
210
211 #[test]
212 fn test_require_any_failure() {
213 let auth = AuthorizationAspect::require_roles(
214 &["admin", "moderator"],
215 || mock_roles(vec!["user"]),
216 AuthMode::RequireAny,
217 );
218
219 assert!(auth.check_authorization().is_err());
220 }
221
222 #[test]
223 fn test_empty_roles() {
224 let auth = AuthorizationAspect::require_role("admin", || mock_roles(vec![]));
225
226 assert!(auth.check_authorization().is_err());
227 }
228
229 #[test]
230 fn test_multiple_roles_user() {
231 let auth = AuthorizationAspect::require_role("admin", || {
232 mock_roles(vec!["user", "moderator", "admin"])
233 });
234
235 assert!(auth.check_authorization().is_ok());
236 }
237}
238
239// ──────────────────────────────────────────────────────────────────────────
240// AllowlistAspect
241// ──────────────────────────────────────────────────────────────────────────
242
243/// Identity-based allowlist aspect.
244///
245/// Admits a call when the supplied identity string is present in a
246/// configured allowlist, optionally honoring a wildcard entry (`"*"`) and an
247/// optional normalization hook for identity comparison.
248///
249/// This shape recurs across messaging-channel integrations in real
250/// codebases: each channel maintains its own list of who is permitted to
251/// talk to the bot. The classical role-based [`AuthorizationAspect`] does
252/// not fit because there are no roles — there is a set of literal identity
253/// strings and a yes/no decision.
254///
255/// # Example
256///
257/// ```rust
258/// use aspect_std::AllowlistAspect;
259///
260/// let policy = AllowlistAspect::new(["user_a".to_string(), "user_b".to_string()]);
261/// assert!(policy.is_allowed("user_a"));
262/// assert!(!policy.is_allowed("intruder"));
263///
264/// // Wildcard.
265/// let open = AllowlistAspect::new(["*".to_string()]);
266/// assert!(open.is_allowed("anyone"));
267///
268/// // Empty list = deny everyone.
269/// let closed = AllowlistAspect::new(Vec::<String>::new());
270/// assert!(!closed.is_allowed("user_a"));
271/// ```
272///
273/// # Normalization
274///
275/// Some identity spaces require canonicalization before comparison: phone
276/// numbers (E.164), emails (case-insensitive), Telegram usernames
277/// (`@`-stripped), Matrix MXIDs (case-insensitive). Pass a normalization
278/// function via [`AllowlistAspect::with_normalizer`].
279///
280/// # Mutability
281///
282/// The allowlist is stored behind an `RwLock` and can be amended at runtime
283/// via [`AllowlistAspect::add`] / [`AllowlistAspect::set`]. This matches
284/// runtime-pairing flows where new identities are added without restart.
285#[derive(Clone)]
286pub struct AllowlistAspect {
287 inner: Arc<AllowlistInner>,
288}
289
290struct AllowlistInner {
291 entries: RwLock<Vec<String>>,
292 normalizer: Option<Box<dyn Fn(&str) -> String + Send + Sync>>,
293 matcher: Option<Box<dyn Fn(&str, &str) -> bool + Send + Sync>>,
294}
295
296impl AllowlistAspect {
297 /// Build an allowlist aspect from an initial set of entries.
298 ///
299 /// The wildcard entry `"*"` admits everyone. An empty list denies
300 /// everyone (fail-closed). Identity comparison is byte-equal by
301 /// default; see [`AllowlistAspect::with_normalizer`] for case-
302 /// insensitive or canonicalized comparison.
303 pub fn new<I, S>(entries: I) -> Self
304 where
305 I: IntoIterator<Item = S>,
306 S: Into<String>,
307 {
308 Self {
309 inner: Arc::new(AllowlistInner {
310 entries: RwLock::new(entries.into_iter().map(Into::into).collect()),
311 normalizer: None,
312 matcher: None,
313 }),
314 }
315 }
316
317 /// Attach a normalization function applied to both the allowlist entries
318 /// and the queried identity before comparison.
319 ///
320 /// Consumes and returns `self`. The normalizer must be deterministic and
321 /// referentially transparent: calling it twice on the same input must
322 /// produce identical output.
323 ///
324 /// Mutually exclusive with [`AllowlistAspect::with_matcher`]: if a
325 /// matcher is installed, the normalizer is bypassed because the
326 /// matcher owns the entry-vs-identity comparison end-to-end.
327 pub fn with_normalizer<F>(mut self, normalizer: F) -> Self
328 where
329 F: Fn(&str) -> String + Send + Sync + 'static,
330 {
331 // We are the sole owner immediately after `new`, so `make_mut`
332 // returns the unique pointee. If a normalizer is added later
333 // (after clones exist), `make_mut` clones the inner — at which
334 // point the normalizer field on the old `Inner` is dropped
335 // (because the trait-object closure is not `Clone`); see the
336 // `Clone for AllowlistInner` impl below for the documented
337 // behavior.
338 let inner = Arc::make_mut(&mut self.inner);
339 inner.normalizer = Some(Box::new(normalizer));
340 self
341 }
342
343 /// Attach a custom match predicate `(entry, identity) -> bool` that
344 /// replaces the default byte-equal (or normalized-equal) comparison.
345 ///
346 /// Consumes and returns `self`. The wildcard `"*"` and empty-list
347 /// short-circuits still apply *before* the matcher runs — the matcher
348 /// is only consulted for non-`"*"` entries against a non-empty list.
349 /// The matcher must be referentially transparent.
350 ///
351 /// Use this when entries and identities are not directly comparable
352 /// — e.g. allowlist entries that describe a *class* of identity
353 /// (`"@example.com"` matches any user at that domain) rather than a
354 /// single literal one. When a matcher is set the normalizer is
355 /// bypassed; the matcher owns the comparison.
356 pub fn with_matcher<F>(mut self, matcher: F) -> Self
357 where
358 F: Fn(&str, &str) -> bool + Send + Sync + 'static,
359 {
360 let inner = Arc::make_mut(&mut self.inner);
361 inner.matcher = Some(Box::new(matcher));
362 self
363 }
364
365 /// Returns `true` if `identity` is admitted by the allowlist.
366 ///
367 /// Semantics: empty list ⇒ deny; `"*"` present ⇒ admit; otherwise
368 /// admit iff the (possibly normalized) identity equals some
369 /// (possibly normalized) entry.
370 pub fn is_allowed(&self, identity: &str) -> bool {
371 let entries = self.inner.entries.read();
372 // Delegate to the stateless entry point so the matching semantics
373 // (empty ⇒ deny, "*" ⇒ admit, normalizer/matcher) live in exactly one
374 // place and cannot drift between the owned and borrowed code paths.
375 let policy = if let Some(matcher) = self.inner.matcher.as_ref() {
376 MatchPolicy::Custom(matcher.as_ref())
377 } else if let Some(norm) = self.inner.normalizer.as_ref() {
378 MatchPolicy::Normalized(norm.as_ref())
379 } else {
380 MatchPolicy::Exact
381 };
382 is_allowed_in(&entries, identity, policy)
383 }
384
385 /// Append `identity` to the allowlist if absent. No-op when `identity`
386 /// is already present (byte-equal, *not* normalized).
387 pub fn add(&self, identity: impl Into<String>) {
388 let identity = identity.into();
389 let mut entries = self.inner.entries.write();
390 if !entries.iter().any(|e| e == &identity) {
391 entries.push(identity);
392 }
393 }
394
395 /// Replace the entire allowlist atomically.
396 pub fn set<I, S>(&self, entries: I)
397 where
398 I: IntoIterator<Item = S>,
399 S: Into<String>,
400 {
401 *self.inner.entries.write() = entries.into_iter().map(Into::into).collect();
402 }
403
404 /// Snapshot the current allowlist as an owned `Vec`. Order-preserving
405 /// relative to insertion / construction.
406 pub fn snapshot(&self) -> Vec<String> {
407 self.inner.entries.read().clone()
408 }
409
410 /// Number of entries currently in the list (including the wildcard
411 /// entry if present).
412 pub fn len(&self) -> usize {
413 self.inner.entries.read().len()
414 }
415
416 /// Returns true when the allowlist is empty (i.e. denies everyone).
417 pub fn is_empty(&self) -> bool {
418 self.inner.entries.read().is_empty()
419 }
420}
421
422/// Borrowed matching policy for the stateless [`is_allowed_in`] entry point.
423///
424/// The stateful [`AllowlistAspect`] owns its entries behind an `RwLock`. That
425/// is the wrong shape for codebases that treat the allowlist's configuration
426/// as the single source of truth and resolve it fresh on every check (so a
427/// config reload is observed without a restart, and no cached copy can drift).
428/// For those callers, [`is_allowed_in`] borrows the freshly-resolved entries
429/// per call and holds nothing; `MatchPolicy` carries the optional
430/// normalization or custom comparison by reference, mirroring
431/// [`AllowlistAspect::with_normalizer`] / [`AllowlistAspect::with_matcher`].
432pub enum MatchPolicy<'a> {
433 /// Byte-equal comparison of entry and identity.
434 Exact,
435 /// Normalize both entry and identity with this function, then compare for
436 /// equality. Mirrors [`AllowlistAspect::with_normalizer`].
437 Normalized(&'a (dyn Fn(&str) -> String + Send + Sync)),
438 /// Replace the entry-vs-identity comparison entirely. The closure receives
439 /// `(entry, identity)` and returns whether they match. The wildcard `"*"`
440 /// and empty-list short-circuits still apply *before* the matcher runs, so
441 /// the matcher is only consulted for non-`"*"` entries against a non-empty
442 /// list. Mirrors [`AllowlistAspect::with_matcher`].
443 Custom(&'a (dyn Fn(&str, &str) -> bool + Send + Sync)),
444}
445
446/// Stateless allowlist check: returns `true` when `identity` is admitted by
447/// `entries` under `policy`. Holds no state — the caller passes the entries
448/// (typically resolved from a canonical config source per call), so this
449/// satisfies single-source-of-truth invariants that forbid caching the
450/// allowlist in a long-lived field.
451///
452/// Semantics are identical to [`AllowlistAspect::is_allowed`]: an empty list
453/// denies everyone; any `"*"` entry admits everyone; otherwise the
454/// (possibly normalized / custom-matched) identity must match some entry.
455#[must_use]
456pub fn is_allowed_in(entries: &[String], identity: &str, policy: MatchPolicy<'_>) -> bool {
457 if entries.is_empty() {
458 return false;
459 }
460 if let MatchPolicy::Custom(matcher) = policy {
461 for entry in entries {
462 if entry == "*" {
463 return true;
464 }
465 if matcher(entry, identity) {
466 return true;
467 }
468 }
469 return false;
470 }
471 let needle = match policy {
472 MatchPolicy::Normalized(f) => Some(f(identity)),
473 _ => None,
474 };
475 for entry in entries {
476 if entry == "*" {
477 return true;
478 }
479 match (&needle, &policy) {
480 (Some(needle), MatchPolicy::Normalized(f)) => {
481 if f(entry) == *needle {
482 return true;
483 }
484 }
485 _ => {
486 if entry == identity {
487 return true;
488 }
489 }
490 }
491 }
492 false
493}
494
495// Workaround: `Arc::make_mut` needs `Clone` on the pointee. We never call
496// it in practice — `with_normalizer` is only meant to be used immediately
497// after `new`, before any clones exist — so we never actually trigger the
498// clone path. Implement `Clone` defensively for the unlikely case.
499impl Clone for AllowlistInner {
500 fn clone(&self) -> Self {
501 Self {
502 entries: RwLock::new(self.entries.read().clone()),
503 // A `Fn` trait object is not `Clone`. If a caller insists on
504 // cloning an `AllowlistAspect` that already has a normalizer
505 // or matcher installed after cloning, those fields are
506 // dropped. This is documented; the canonical use is to call
507 // `with_normalizer` / `with_matcher` immediately after
508 // `new`, before any clones exist.
509 normalizer: None,
510 matcher: None,
511 }
512 }
513}
514
515/// Per-call context bound to the most recent guarded join point. Set by
516/// the host before `proceed()` is invoked; cleared by `release`.
517#[derive(Clone, Debug)]
518pub struct AllowlistCall {
519 /// The identity being checked at this join point.
520 pub identity: String,
521}
522
523impl Aspect for AllowlistAspect {
524 fn before(&self, _ctx: &JoinPoint) {
525 // No-op. Denial happens in `around`, where we can short-circuit.
526 }
527
528 fn around(&self, pjp: ProceedingJoinPoint) -> Result<Box<dyn Any>, AspectError> {
529 // The around path is provided so the aspect can be applied via
530 // `#[aspect(...)]` weaving when the migration is ready to use that
531 // path. The current zeroclaw migration uses the direct
532 // `is_allowed(...)` query API instead and does not go through
533 // `around`; we keep the implementation honest by returning the
534 // proceed result unchanged here. A future macro-driven migration
535 // would extend `ProceedingJoinPoint` to carry the identity to
536 // check, at which point this path would call `is_allowed`.
537 pjp.proceed()
538 }
539}
540
541#[cfg(test)]
542mod allowlist_tests {
543 use super::*;
544
545 #[test]
546 fn empty_list_denies_everyone() {
547 let policy = AllowlistAspect::new(Vec::<String>::new());
548 assert!(!policy.is_allowed("alice"));
549 assert!(!policy.is_allowed(""));
550 }
551
552 #[test]
553 fn wildcard_admits_everyone() {
554 let policy = AllowlistAspect::new(["*".to_string()]);
555 assert!(policy.is_allowed("alice"));
556 assert!(policy.is_allowed("anyone"));
557 assert!(policy.is_allowed(""));
558 }
559
560 #[test]
561 fn specific_identity() {
562 let policy = AllowlistAspect::new(["alice".to_string(), "bob".to_string()]);
563 assert!(policy.is_allowed("alice"));
564 assert!(policy.is_allowed("bob"));
565 assert!(!policy.is_allowed("charlie"));
566 }
567
568 #[test]
569 fn wildcard_mixed_with_specific() {
570 // Matches existing zeroclaw behavior: wildcard wins regardless of
571 // position.
572 let policy = AllowlistAspect::new(["alice".to_string(), "*".to_string()]);
573 assert!(policy.is_allowed("alice"));
574 assert!(policy.is_allowed("charlie"));
575 }
576
577 #[test]
578 fn normalizer_case_insensitive() {
579 let policy = AllowlistAspect::new(["Alice@Example.com".to_string()])
580 .with_normalizer(|s| s.to_ascii_lowercase());
581 assert!(policy.is_allowed("alice@example.com"));
582 assert!(policy.is_allowed("ALICE@EXAMPLE.COM"));
583 assert!(!policy.is_allowed("bob@example.com"));
584 }
585
586 #[test]
587 fn normalizer_phone_strip() {
588 // Mirrors how zeroclaw's whatsapp_web normalizes E.164: strip
589 // everything but digits and the leading '+'.
590 let policy =
591 AllowlistAspect::new(["+1-555-0100".to_string()]).with_normalizer(|s| {
592 let mut out = String::new();
593 let mut chars = s.chars();
594 if let Some('+') = chars.clone().next() {
595 out.push('+');
596 chars.next();
597 }
598 for c in chars {
599 if c.is_ascii_digit() {
600 out.push(c);
601 }
602 }
603 out
604 });
605 assert!(policy.is_allowed("+15550100"));
606 assert!(policy.is_allowed("+1 555 0100"));
607 assert!(!policy.is_allowed("+15550101"));
608 }
609
610 #[test]
611 fn add_appends_unique() {
612 let policy = AllowlistAspect::new(["alice".to_string()]);
613 policy.add("bob");
614 policy.add("bob"); // duplicate ignored
615 assert_eq!(policy.len(), 2);
616 assert!(policy.is_allowed("bob"));
617 }
618
619 #[test]
620 fn set_replaces_atomically() {
621 let policy = AllowlistAspect::new(["alice".to_string()]);
622 policy.set(["bob".to_string(), "carol".to_string()]);
623 assert!(!policy.is_allowed("alice"));
624 assert!(policy.is_allowed("bob"));
625 assert!(policy.is_allowed("carol"));
626 assert_eq!(policy.len(), 2);
627 }
628
629 #[test]
630 fn snapshot_returns_owned_copy() {
631 let policy = AllowlistAspect::new(["alice".to_string(), "bob".to_string()]);
632 let snap = policy.snapshot();
633 assert_eq!(snap, vec!["alice".to_string(), "bob".to_string()]);
634 // Mutating the policy does not affect the snapshot.
635 policy.add("carol");
636 assert_eq!(snap.len(), 2);
637 }
638
639 #[test]
640 fn clone_shares_underlying_list() {
641 let policy_a = AllowlistAspect::new(["alice".to_string()]);
642 let policy_b = policy_a.clone();
643 policy_b.add("bob");
644 // Both views see the addition — clones share `Arc<Inner>`.
645 assert!(policy_a.is_allowed("bob"));
646 }
647
648 /// Parity test: matches the exact semantics of zeroclaw's existing
649 /// archetype-A `is_user_allowed` shape (`allowed.iter().any(|u| u ==
650 /// "*" || u == identity)`). The aspect must match this Boolean
651 /// truth table for every (allowlist, identity) pair below, so the
652 /// migration is provably behavior-preserving.
653 #[test]
654 fn parity_with_zeroclaw_archetype_a() {
655 let cases: Vec<(Vec<&str>, &str, bool)> = vec![
656 (vec![], "alice", false),
657 (vec![], "", false),
658 (vec!["*"], "alice", true),
659 (vec!["*"], "", true),
660 (vec!["alice"], "alice", true),
661 (vec!["alice"], "bob", false),
662 (vec!["alice", "bob"], "bob", true),
663 (vec!["alice", "*"], "anyone", true),
664 (vec!["*", "alice"], "anyone", true),
665 (vec!["alice"], "ALICE", false), // case-sensitive by default
666 (vec!["alice"], "alice ", false), // whitespace-sensitive by default
667 ];
668 for (entries, identity, expected) in cases {
669 let policy = AllowlistAspect::new(entries.iter().map(|s| s.to_string()));
670 let baseline = entries.iter().any(|u| *u == "*" || *u == identity);
671 assert_eq!(
672 baseline, expected,
673 "baseline semantics drifted for entries={entries:?} identity={identity:?}"
674 );
675 assert_eq!(
676 policy.is_allowed(identity),
677 expected,
678 "AllowlistAspect mismatched zeroclaw archetype-A for entries={entries:?} identity={identity:?}"
679 );
680 }
681 }
682
683 /// The email/gmail_push shape: entries may be a full email
684 /// ("alice@example.com"), a domain with `@` prefix
685 /// ("@example.com"), or a bare domain ("example.com"). All
686 /// comparisons are ASCII-case-insensitive.
687 fn email_domain_or_full_match(entry: &str, email: &str) -> bool {
688 let email_lower = email.to_ascii_lowercase();
689 if let Some(domain) = entry.strip_prefix('@') {
690 email_lower.ends_with(&format!("@{}", domain.to_ascii_lowercase()))
691 } else if entry.contains('@') {
692 entry.eq_ignore_ascii_case(email)
693 } else {
694 email_lower.ends_with(&format!("@{}", entry.to_ascii_lowercase()))
695 }
696 }
697
698 #[test]
699 fn matcher_email_domain_or_full() {
700 let policy = AllowlistAspect::new([
701 "alice@example.com".to_string(),
702 "@allowed.com".to_string(),
703 "bare.com".to_string(),
704 ])
705 .with_matcher(email_domain_or_full_match);
706 assert!(policy.is_allowed("alice@example.com"));
707 assert!(policy.is_allowed("ALICE@Example.COM")); // case-insensitive full match
708 assert!(policy.is_allowed("anyone@allowed.com"));
709 assert!(policy.is_allowed("anyone@bare.com"));
710 assert!(!policy.is_allowed("bob@example.com")); // wrong local part, no domain rule
711 assert!(!policy.is_allowed("anyone@other.com"));
712 }
713
714 #[test]
715 fn matcher_respects_wildcard_and_empty_shortcircuits() {
716 // Empty list still denies even with a permissive matcher.
717 let empty =
718 AllowlistAspect::new(Vec::<String>::new()).with_matcher(|_e, _i| true);
719 assert!(!empty.is_allowed("anyone"));
720
721 // Wildcard still admits without ever calling the matcher.
722 let wild = AllowlistAspect::new(["*".to_string()])
723 .with_matcher(|_e, _i| panic!("matcher must not run when '*' present"));
724 assert!(wild.is_allowed("anyone"));
725 }
726
727 #[test]
728 fn matcher_takes_precedence_over_normalizer() {
729 // Normalizer would map both sides to lowercase, but the matcher
730 // owns the comparison and chooses to be strict-equal. The
731 // normalizer must be bypassed.
732 let policy = AllowlistAspect::new(["Alice".to_string()])
733 .with_normalizer(|s| s.to_ascii_lowercase())
734 .with_matcher(|e, i| e == i);
735 assert!(policy.is_allowed("Alice"));
736 assert!(!policy.is_allowed("alice"));
737 }
738
739 // --- stateless `is_allowed_in` -------------------------------------
740
741 #[test]
742 fn stateless_empty_and_wildcard() {
743 assert!(!is_allowed_in(&[], "alice", MatchPolicy::Exact));
744 let star = ["*".to_string()];
745 assert!(is_allowed_in(&star, "anyone", MatchPolicy::Exact));
746 assert!(is_allowed_in(&star, "", MatchPolicy::Exact));
747 }
748
749 #[test]
750 fn stateless_exact_matches_owned() {
751 // The borrowed path returns identical results to the owned
752 // `AllowlistAspect::is_allowed` across a fixed truth table.
753 let cases: &[(&[&str], &str)] = &[
754 (&[], "alice"),
755 (&["*"], "alice"),
756 (&["alice", "bob"], "bob"),
757 (&["alice"], "charlie"),
758 (&["alice", "*"], "charlie"),
759 ];
760 for (entries, id) in cases {
761 let owned: Vec<String> = entries.iter().map(|s| (*s).to_string()).collect();
762 let aspect = AllowlistAspect::new(owned.clone());
763 assert_eq!(
764 is_allowed_in(&owned, id, MatchPolicy::Exact),
765 aspect.is_allowed(id),
766 "drift for entries={entries:?} id={id:?}",
767 );
768 }
769 }
770
771 #[test]
772 fn stateless_email_domain_class_matcher() {
773 // Mirrors zeroclaw email/gmail_push: "@host" / bare "host" match the
774 // whole domain, "user@host" is a full case-insensitive address.
775 let matcher = |allowed: &str, email: &str| -> bool {
776 let email_lower = email.to_lowercase();
777 if let Some(rest) = allowed.strip_prefix('@') {
778 email_lower.ends_with(&format!("@{}", rest.to_lowercase()))
779 } else if allowed.contains('@') {
780 allowed.eq_ignore_ascii_case(email)
781 } else {
782 email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
783 }
784 };
785 let entries = vec!["@example.com".to_string(), "boss@corp.io".to_string()];
786 let p = || MatchPolicy::Custom(&matcher);
787 assert!(is_allowed_in(&entries, "anyone@example.com", p()));
788 assert!(is_allowed_in(&entries, "Anyone@Example.com", p()));
789 assert!(is_allowed_in(&entries, "BOSS@corp.io", p()));
790 assert!(!is_allowed_in(&entries, "boss@other.io", p()));
791 assert!(!is_allowed_in(&entries, "user@evil.com", p()));
792 }
793
794 #[test]
795 fn stateless_phone_normalizer() {
796 // Mirrors zeroclaw whatsapp_web E.164 normalization.
797 let norm = |s: &str| -> String {
798 let mut out = String::new();
799 let mut chars = s.chars();
800 if let Some('+') = chars.clone().next() {
801 out.push('+');
802 chars.next();
803 }
804 out.extend(chars.filter(|c| c.is_ascii_digit()));
805 out
806 };
807 let entries = vec!["+1-555-0100".to_string()];
808 assert!(is_allowed_in(&entries, "+1 555 0100", MatchPolicy::Normalized(&norm)));
809 assert!(is_allowed_in(&entries, "+15550100", MatchPolicy::Normalized(&norm)));
810 assert!(!is_allowed_in(&entries, "+15550101", MatchPolicy::Normalized(&norm)));
811 }
812}