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}