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        if entries.is_empty() {
373            return false;
374        }
375        if let Some(matcher) = self.inner.matcher.as_ref() {
376            for entry in entries.iter() {
377                if entry == "*" {
378                    return true;
379                }
380                if matcher(entry, identity) {
381                    return true;
382                }
383            }
384            return false;
385        }
386        let norm = self.inner.normalizer.as_ref();
387        let needle = norm.map(|f| f(identity));
388        for entry in entries.iter() {
389            if entry == "*" {
390                return true;
391            }
392            match (&needle, norm) {
393                (Some(needle), Some(f)) => {
394                    if f(entry) == *needle {
395                        return true;
396                    }
397                }
398                _ => {
399                    if entry == identity {
400                        return true;
401                    }
402                }
403            }
404        }
405        false
406    }
407
408    /// Append `identity` to the allowlist if absent. No-op when `identity`
409    /// is already present (byte-equal, *not* normalized).
410    pub fn add(&self, identity: impl Into<String>) {
411        let identity = identity.into();
412        let mut entries = self.inner.entries.write();
413        if !entries.iter().any(|e| e == &identity) {
414            entries.push(identity);
415        }
416    }
417
418    /// Replace the entire allowlist atomically.
419    pub fn set<I, S>(&self, entries: I)
420    where
421        I: IntoIterator<Item = S>,
422        S: Into<String>,
423    {
424        *self.inner.entries.write() = entries.into_iter().map(Into::into).collect();
425    }
426
427    /// Snapshot the current allowlist as an owned `Vec`. Order-preserving
428    /// relative to insertion / construction.
429    pub fn snapshot(&self) -> Vec<String> {
430        self.inner.entries.read().clone()
431    }
432
433    /// Number of entries currently in the list (including the wildcard
434    /// entry if present).
435    pub fn len(&self) -> usize {
436        self.inner.entries.read().len()
437    }
438
439    /// Returns true when the allowlist is empty (i.e. denies everyone).
440    pub fn is_empty(&self) -> bool {
441        self.inner.entries.read().is_empty()
442    }
443}
444
445// Workaround: `Arc::make_mut` needs `Clone` on the pointee. We never call
446// it in practice — `with_normalizer` is only meant to be used immediately
447// after `new`, before any clones exist — so we never actually trigger the
448// clone path. Implement `Clone` defensively for the unlikely case.
449impl Clone for AllowlistInner {
450    fn clone(&self) -> Self {
451        Self {
452            entries: RwLock::new(self.entries.read().clone()),
453            // A `Fn` trait object is not `Clone`. If a caller insists on
454            // cloning an `AllowlistAspect` that already has a normalizer
455            // or matcher installed after cloning, those fields are
456            // dropped. This is documented; the canonical use is to call
457            // `with_normalizer` / `with_matcher` immediately after
458            // `new`, before any clones exist.
459            normalizer: None,
460            matcher: None,
461        }
462    }
463}
464
465/// Per-call context bound to the most recent guarded join point. Set by
466/// the host before `proceed()` is invoked; cleared by `release`.
467#[derive(Clone, Debug)]
468pub struct AllowlistCall {
469    /// The identity being checked at this join point.
470    pub identity: String,
471}
472
473impl Aspect for AllowlistAspect {
474    fn before(&self, _ctx: &JoinPoint) {
475        // No-op. Denial happens in `around`, where we can short-circuit.
476    }
477
478    fn around(&self, pjp: ProceedingJoinPoint) -> Result<Box<dyn Any>, AspectError> {
479        // The around path is provided so the aspect can be applied via
480        // `#[aspect(...)]` weaving when the migration is ready to use that
481        // path. The current zeroclaw migration uses the direct
482        // `is_allowed(...)` query API instead and does not go through
483        // `around`; we keep the implementation honest by returning the
484        // proceed result unchanged here. A future macro-driven migration
485        // would extend `ProceedingJoinPoint` to carry the identity to
486        // check, at which point this path would call `is_allowed`.
487        pjp.proceed()
488    }
489}
490
491#[cfg(test)]
492mod allowlist_tests {
493    use super::*;
494
495    #[test]
496    fn empty_list_denies_everyone() {
497        let policy = AllowlistAspect::new(Vec::<String>::new());
498        assert!(!policy.is_allowed("alice"));
499        assert!(!policy.is_allowed(""));
500    }
501
502    #[test]
503    fn wildcard_admits_everyone() {
504        let policy = AllowlistAspect::new(["*".to_string()]);
505        assert!(policy.is_allowed("alice"));
506        assert!(policy.is_allowed("anyone"));
507        assert!(policy.is_allowed(""));
508    }
509
510    #[test]
511    fn specific_identity() {
512        let policy = AllowlistAspect::new(["alice".to_string(), "bob".to_string()]);
513        assert!(policy.is_allowed("alice"));
514        assert!(policy.is_allowed("bob"));
515        assert!(!policy.is_allowed("charlie"));
516    }
517
518    #[test]
519    fn wildcard_mixed_with_specific() {
520        // Matches existing zeroclaw behavior: wildcard wins regardless of
521        // position.
522        let policy = AllowlistAspect::new(["alice".to_string(), "*".to_string()]);
523        assert!(policy.is_allowed("alice"));
524        assert!(policy.is_allowed("charlie"));
525    }
526
527    #[test]
528    fn normalizer_case_insensitive() {
529        let policy = AllowlistAspect::new(["Alice@Example.com".to_string()])
530            .with_normalizer(|s| s.to_ascii_lowercase());
531        assert!(policy.is_allowed("alice@example.com"));
532        assert!(policy.is_allowed("ALICE@EXAMPLE.COM"));
533        assert!(!policy.is_allowed("bob@example.com"));
534    }
535
536    #[test]
537    fn normalizer_phone_strip() {
538        // Mirrors how zeroclaw's whatsapp_web normalizes E.164: strip
539        // everything but digits and the leading '+'.
540        let policy =
541            AllowlistAspect::new(["+1-555-0100".to_string()]).with_normalizer(|s| {
542                let mut out = String::new();
543                let mut chars = s.chars();
544                if let Some('+') = chars.clone().next() {
545                    out.push('+');
546                    chars.next();
547                }
548                for c in chars {
549                    if c.is_ascii_digit() {
550                        out.push(c);
551                    }
552                }
553                out
554            });
555        assert!(policy.is_allowed("+15550100"));
556        assert!(policy.is_allowed("+1 555 0100"));
557        assert!(!policy.is_allowed("+15550101"));
558    }
559
560    #[test]
561    fn add_appends_unique() {
562        let policy = AllowlistAspect::new(["alice".to_string()]);
563        policy.add("bob");
564        policy.add("bob"); // duplicate ignored
565        assert_eq!(policy.len(), 2);
566        assert!(policy.is_allowed("bob"));
567    }
568
569    #[test]
570    fn set_replaces_atomically() {
571        let policy = AllowlistAspect::new(["alice".to_string()]);
572        policy.set(["bob".to_string(), "carol".to_string()]);
573        assert!(!policy.is_allowed("alice"));
574        assert!(policy.is_allowed("bob"));
575        assert!(policy.is_allowed("carol"));
576        assert_eq!(policy.len(), 2);
577    }
578
579    #[test]
580    fn snapshot_returns_owned_copy() {
581        let policy = AllowlistAspect::new(["alice".to_string(), "bob".to_string()]);
582        let snap = policy.snapshot();
583        assert_eq!(snap, vec!["alice".to_string(), "bob".to_string()]);
584        // Mutating the policy does not affect the snapshot.
585        policy.add("carol");
586        assert_eq!(snap.len(), 2);
587    }
588
589    #[test]
590    fn clone_shares_underlying_list() {
591        let policy_a = AllowlistAspect::new(["alice".to_string()]);
592        let policy_b = policy_a.clone();
593        policy_b.add("bob");
594        // Both views see the addition — clones share `Arc<Inner>`.
595        assert!(policy_a.is_allowed("bob"));
596    }
597
598    /// Parity test: matches the exact semantics of zeroclaw's existing
599    /// archetype-A `is_user_allowed` shape (`allowed.iter().any(|u| u ==
600    /// "*" || u == identity)`). The aspect must match this Boolean
601    /// truth table for every (allowlist, identity) pair below, so the
602    /// migration is provably behavior-preserving.
603    #[test]
604    fn parity_with_zeroclaw_archetype_a() {
605        let cases: Vec<(Vec<&str>, &str, bool)> = vec![
606            (vec![], "alice", false),
607            (vec![], "", false),
608            (vec!["*"], "alice", true),
609            (vec!["*"], "", true),
610            (vec!["alice"], "alice", true),
611            (vec!["alice"], "bob", false),
612            (vec!["alice", "bob"], "bob", true),
613            (vec!["alice", "*"], "anyone", true),
614            (vec!["*", "alice"], "anyone", true),
615            (vec!["alice"], "ALICE", false), // case-sensitive by default
616            (vec!["alice"], "alice ", false), // whitespace-sensitive by default
617        ];
618        for (entries, identity, expected) in cases {
619            let policy = AllowlistAspect::new(entries.iter().map(|s| s.to_string()));
620            let baseline = entries.iter().any(|u| *u == "*" || *u == identity);
621            assert_eq!(
622                baseline, expected,
623                "baseline semantics drifted for entries={entries:?} identity={identity:?}"
624            );
625            assert_eq!(
626                policy.is_allowed(identity),
627                expected,
628                "AllowlistAspect mismatched zeroclaw archetype-A for entries={entries:?} identity={identity:?}"
629            );
630        }
631    }
632
633    /// The email/gmail_push shape: entries may be a full email
634    /// ("alice@example.com"), a domain with `@` prefix
635    /// ("@example.com"), or a bare domain ("example.com"). All
636    /// comparisons are ASCII-case-insensitive.
637    fn email_domain_or_full_match(entry: &str, email: &str) -> bool {
638        let email_lower = email.to_ascii_lowercase();
639        if let Some(domain) = entry.strip_prefix('@') {
640            email_lower.ends_with(&format!("@{}", domain.to_ascii_lowercase()))
641        } else if entry.contains('@') {
642            entry.eq_ignore_ascii_case(email)
643        } else {
644            email_lower.ends_with(&format!("@{}", entry.to_ascii_lowercase()))
645        }
646    }
647
648    #[test]
649    fn matcher_email_domain_or_full() {
650        let policy = AllowlistAspect::new([
651            "alice@example.com".to_string(),
652            "@allowed.com".to_string(),
653            "bare.com".to_string(),
654        ])
655        .with_matcher(email_domain_or_full_match);
656        assert!(policy.is_allowed("alice@example.com"));
657        assert!(policy.is_allowed("ALICE@Example.COM")); // case-insensitive full match
658        assert!(policy.is_allowed("anyone@allowed.com"));
659        assert!(policy.is_allowed("anyone@bare.com"));
660        assert!(!policy.is_allowed("bob@example.com")); // wrong local part, no domain rule
661        assert!(!policy.is_allowed("anyone@other.com"));
662    }
663
664    #[test]
665    fn matcher_respects_wildcard_and_empty_shortcircuits() {
666        // Empty list still denies even with a permissive matcher.
667        let empty =
668            AllowlistAspect::new(Vec::<String>::new()).with_matcher(|_e, _i| true);
669        assert!(!empty.is_allowed("anyone"));
670
671        // Wildcard still admits without ever calling the matcher.
672        let wild = AllowlistAspect::new(["*".to_string()])
673            .with_matcher(|_e, _i| panic!("matcher must not run when '*' present"));
674        assert!(wild.is_allowed("anyone"));
675    }
676
677    #[test]
678    fn matcher_takes_precedence_over_normalizer() {
679        // Normalizer would map both sides to lowercase, but the matcher
680        // owns the comparison and chooses to be strict-equal. The
681        // normalizer must be bypassed.
682        let policy = AllowlistAspect::new(["Alice".to_string()])
683            .with_normalizer(|s| s.to_ascii_lowercase())
684            .with_matcher(|e, i| e == i);
685        assert!(policy.is_allowed("Alice"));
686        assert!(!policy.is_allowed("alice"));
687    }
688}