Skip to main content

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}