axum_acl/extractor.rs
1//! Role and ID extraction from HTTP requests.
2//!
3//! This module provides traits for extracting user identity from requests:
4//! - [`RoleExtractor`]: Extract roles as a `u32` bitmask (up to 32 roles)
5//! - [`IdExtractor`]: Extract user/resource ID as a `String`
6//!
7//! ## Custom Role Translation
8//!
9//! If your system uses a different role scheme (e.g., string roles, enums),
10//! implement `RoleExtractor` to translate to u32 bitmask:
11//!
12//! ```
13//! use axum_acl::{RoleExtractor, RoleExtractionResult};
14//! use http::Request;
15//!
16//! // Your role enum
17//! enum Role { Admin, User, Guest }
18//!
19//! // Define bit positions
20//! const ROLE_ADMIN: u32 = 1 << 0;
21//! const ROLE_USER: u32 = 1 << 1;
22//! const ROLE_GUEST: u32 = 1 << 2;
23//!
24//! fn roles_to_mask(roles: &[Role]) -> u32 {
25//! roles.iter().fold(0u32, |mask, role| {
26//! mask | match role {
27//! Role::Admin => ROLE_ADMIN,
28//! Role::User => ROLE_USER,
29//! Role::Guest => ROLE_GUEST,
30//! }
31//! })
32//! }
33//! ```
34//!
35//! ## Path Parameter ID Matching
36//!
37//! For paths like `/api/boat/{id}/size`, the `{id}` is matched against
38//! the user's ID from `IdExtractor`. This enables ownership-based access:
39//!
40//! ```text
41//! Rule: /api/boat/{id}/** role=USER, id={id} -> allow
42//! User: id="boat-123", roles=USER
43//! Path: /api/boat/boat-123/size -> ALLOWED (id matches)
44//! Path: /api/boat/boat-456/size -> DENIED (id doesn't match)
45//! ```
46
47use crate::rule::BitmaskAuth;
48use http::Request;
49use std::sync::Arc;
50
51/// Result of role extraction.
52#[derive(Debug, Clone)]
53pub enum RoleExtractionResult {
54 /// Roles were successfully extracted (u32 bitmask).
55 Roles(u32),
56 /// No role could be extracted (user is anonymous/guest).
57 Anonymous,
58 /// An error occurred during extraction.
59 Error(String),
60}
61
62impl RoleExtractionResult {
63 /// Get the roles bitmask, returning a default for anonymous users.
64 pub fn roles_or(&self, default: u32) -> u32 {
65 match self {
66 Self::Roles(roles) => *roles,
67 Self::Anonymous => default,
68 Self::Error(_) => default,
69 }
70 }
71
72 /// Get the roles, returning 0 (no roles) for anonymous users.
73 pub fn roles_or_none(&self) -> u32 {
74 self.roles_or(0)
75 }
76}
77
78/// Trait for extracting roles from HTTP requests.
79///
80/// Implement this trait to customize how roles are determined from incoming
81/// requests. This allows integration with various authentication systems.
82///
83/// Roles are represented as `u32` bitmasks, allowing multiple roles per user.
84///
85/// The trait is synchronous because role extraction typically involves
86/// reading headers or request extensions, which doesn't require async.
87///
88/// # Example
89/// ```
90/// use axum_acl::{RoleExtractor, RoleExtractionResult};
91/// use http::Request;
92///
93/// const ROLE_ADMIN: u32 = 0b001;
94/// const ROLE_USER: u32 = 0b010;
95///
96/// /// Extract roles from a custom header as a bitmask.
97/// struct CustomRolesExtractor;
98///
99/// impl<B> RoleExtractor<B> for CustomRolesExtractor {
100/// fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
101/// match request.headers().get("X-Roles") {
102/// Some(value) => {
103/// match value.to_str() {
104/// Ok(s) => {
105/// // Parse comma-separated role names to bitmask
106/// let mut mask = 0u32;
107/// for role in s.split(',') {
108/// match role.trim() {
109/// "admin" => mask |= ROLE_ADMIN,
110/// "user" => mask |= ROLE_USER,
111/// _ => {}
112/// }
113/// }
114/// RoleExtractionResult::Roles(mask)
115/// }
116/// Err(_) => RoleExtractionResult::Anonymous,
117/// }
118/// }
119/// None => RoleExtractionResult::Anonymous,
120/// }
121/// }
122/// }
123/// ```
124pub trait RoleExtractor<B>: Send + Sync {
125 /// Extract the roles bitmask from an HTTP request.
126 fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult;
127}
128
129// Implement for Arc<T> where T: RoleExtractor
130impl<B, T: RoleExtractor<B>> RoleExtractor<B> for Arc<T> {
131 fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
132 (**self).extract_roles(request)
133 }
134}
135
136// Implement for Box<T> where T: RoleExtractor
137impl<B, T: RoleExtractor<B> + ?Sized> RoleExtractor<B> for Box<T> {
138 fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
139 (**self).extract_roles(request)
140 }
141}
142
143/// Extract roles bitmask from an HTTP header.
144///
145/// The header value is parsed as a u32 bitmask directly, or you can use
146/// a custom parser function to convert header values to bitmasks.
147///
148/// # Example
149/// ```
150/// use axum_acl::HeaderRoleExtractor;
151///
152/// // Extract roles bitmask directly from X-Roles header (as decimal or hex)
153/// let extractor = HeaderRoleExtractor::new("X-Roles");
154///
155/// // With a custom default roles bitmask for missing headers
156/// let extractor = HeaderRoleExtractor::new("X-Roles")
157/// .with_default_roles(0b100); // guest role
158/// ```
159#[derive(Debug, Clone)]
160pub struct HeaderRoleExtractor {
161 header_name: String,
162 default_roles: u32,
163}
164
165impl HeaderRoleExtractor {
166 /// Create a new header role extractor.
167 pub fn new(header_name: impl Into<String>) -> Self {
168 Self {
169 header_name: header_name.into(),
170 default_roles: 0,
171 }
172 }
173
174 /// Set default roles bitmask to use when the header is missing.
175 pub fn with_default_roles(mut self, roles: u32) -> Self {
176 self.default_roles = roles;
177 self
178 }
179}
180
181impl<B> RoleExtractor<B> for HeaderRoleExtractor {
182 fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
183 match request.headers().get(&self.header_name) {
184 Some(value) => match value.to_str() {
185 Ok(s) if !s.is_empty() => {
186 // Try parsing as decimal first, then hex (with 0x prefix)
187 let trimmed = s.trim();
188 if let Ok(roles) = trimmed.parse::<u32>() {
189 RoleExtractionResult::Roles(roles)
190 } else if let Some(hex) = trimmed.strip_prefix("0x") {
191 u32::from_str_radix(hex, 16)
192 .map(RoleExtractionResult::Roles)
193 .unwrap_or_else(|_| {
194 if self.default_roles != 0 {
195 RoleExtractionResult::Roles(self.default_roles)
196 } else {
197 RoleExtractionResult::Anonymous
198 }
199 })
200 } else if self.default_roles != 0 {
201 RoleExtractionResult::Roles(self.default_roles)
202 } else {
203 RoleExtractionResult::Anonymous
204 }
205 }
206 _ => {
207 if self.default_roles != 0 {
208 RoleExtractionResult::Roles(self.default_roles)
209 } else {
210 RoleExtractionResult::Anonymous
211 }
212 }
213 },
214 None => {
215 if self.default_roles != 0 {
216 RoleExtractionResult::Roles(self.default_roles)
217 } else {
218 RoleExtractionResult::Anonymous
219 }
220 }
221 }
222 }
223}
224
225/// Extract roles from a request extension.
226///
227/// This extractor looks for roles that were set by a previous middleware
228/// (e.g., an authentication middleware) as a request extension.
229///
230/// # Example
231/// ```
232/// use axum_acl::ExtensionRoleExtractor;
233///
234/// // The authentication middleware should insert a Roles struct into extensions
235/// #[derive(Clone)]
236/// struct UserRoles(u32);
237///
238/// let extractor = ExtensionRoleExtractor::<UserRoles>::new(|roles| roles.0);
239/// ```
240pub struct ExtensionRoleExtractor<T> {
241 extract_fn: Box<dyn Fn(&T) -> u32 + Send + Sync>,
242}
243
244impl<T> ExtensionRoleExtractor<T> {
245 /// Create a new extension role extractor.
246 ///
247 /// The `extract_fn` converts the extension type to a roles bitmask.
248 pub fn new<F>(extract_fn: F) -> Self
249 where
250 F: Fn(&T) -> u32 + Send + Sync + 'static,
251 {
252 Self {
253 extract_fn: Box::new(extract_fn),
254 }
255 }
256}
257
258impl<T> std::fmt::Debug for ExtensionRoleExtractor<T> {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 f.debug_struct("ExtensionRoleExtractor")
261 .field("type", &std::any::type_name::<T>())
262 .finish()
263 }
264}
265
266impl<B, T: Clone + Send + Sync + 'static> RoleExtractor<B> for ExtensionRoleExtractor<T> {
267 fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
268 match request.extensions().get::<T>() {
269 Some(ext) => RoleExtractionResult::Roles((self.extract_fn)(ext)),
270 None => RoleExtractionResult::Anonymous,
271 }
272 }
273}
274
275/// A roles extractor that always returns fixed roles.
276///
277/// Useful for testing or for routes that should always use specific roles.
278#[derive(Debug, Clone)]
279pub struct FixedRoleExtractor {
280 roles: u32,
281}
282
283impl FixedRoleExtractor {
284 /// Create a new fixed roles extractor.
285 pub fn new(roles: u32) -> Self {
286 Self { roles }
287 }
288}
289
290impl<B> RoleExtractor<B> for FixedRoleExtractor {
291 fn extract_roles(&self, _request: &Request<B>) -> RoleExtractionResult {
292 RoleExtractionResult::Roles(self.roles)
293 }
294}
295
296/// A role extractor that always returns anonymous (no roles).
297#[derive(Debug, Clone, Default)]
298pub struct AnonymousRoleExtractor;
299
300impl AnonymousRoleExtractor {
301 /// Create a new anonymous role extractor.
302 pub fn new() -> Self {
303 Self
304 }
305}
306
307impl<B> RoleExtractor<B> for AnonymousRoleExtractor {
308 fn extract_roles(&self, _request: &Request<B>) -> RoleExtractionResult {
309 RoleExtractionResult::Anonymous
310 }
311}
312
313/// A composite extractor that tries multiple extractors in order.
314///
315/// Returns the first successful roles extraction, or anonymous if all fail.
316/// Roles from multiple extractors are NOT combined - only the first match is used.
317pub struct ChainedRoleExtractor<B> {
318 extractors: Vec<Box<dyn RoleExtractor<B>>>,
319}
320
321// ============================================================================
322// ID Extraction
323// ============================================================================
324
325/// Result of ID extraction.
326#[derive(Debug, Clone)]
327pub enum IdExtractionResult {
328 /// ID was successfully extracted.
329 Id(String),
330 /// No ID could be extracted (anonymous user).
331 Anonymous,
332 /// An error occurred during extraction.
333 Error(String),
334}
335
336impl IdExtractionResult {
337 /// Get the ID, returning a default for anonymous users.
338 pub fn id_or(&self, default: impl Into<String>) -> String {
339 match self {
340 Self::Id(id) => id.clone(),
341 Self::Anonymous => default.into(),
342 Self::Error(_) => default.into(),
343 }
344 }
345
346 /// Get the ID, returning "*" (wildcard) for anonymous users.
347 pub fn id_or_wildcard(&self) -> String {
348 self.id_or("*")
349 }
350}
351
352/// Trait for extracting user/resource ID from HTTP requests.
353///
354/// Implement this trait to customize how user IDs are determined from
355/// incoming requests. The ID is used for:
356/// - Matching against `{id}` path parameters
357/// - Direct ID matching in ACL rules
358///
359/// # Example: JWT User ID
360/// ```
361/// use axum_acl::{IdExtractor, IdExtractionResult};
362/// use http::Request;
363///
364/// struct JwtIdExtractor;
365///
366/// impl<B> IdExtractor<B> for JwtIdExtractor {
367/// fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
368/// // In practice, you'd decode the JWT and extract the user ID
369/// if let Some(auth) = request.headers().get("Authorization") {
370/// if let Ok(s) = auth.to_str() {
371/// // Simplified: extract user ID from token
372/// if s.starts_with("Bearer ") {
373/// return IdExtractionResult::Id("user-123".to_string());
374/// }
375/// }
376/// }
377/// IdExtractionResult::Anonymous
378/// }
379/// }
380/// ```
381///
382/// # Example: Path-based Resource ID
383/// ```
384/// use axum_acl::{IdExtractor, IdExtractionResult};
385/// use http::Request;
386///
387/// /// Extract resource ID from path like /api/boat/{id}/...
388/// struct PathIdExtractor {
389/// prefix: String, // e.g., "/api/boat/"
390/// }
391///
392/// impl<B> IdExtractor<B> for PathIdExtractor {
393/// fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
394/// let path = request.uri().path();
395/// if let Some(rest) = path.strip_prefix(&self.prefix) {
396/// // Get the next path segment as the ID
397/// if let Some(id) = rest.split('/').next() {
398/// if !id.is_empty() {
399/// return IdExtractionResult::Id(id.to_string());
400/// }
401/// }
402/// }
403/// IdExtractionResult::Anonymous
404/// }
405/// }
406/// ```
407pub trait IdExtractor<B>: Send + Sync {
408 /// Extract the user/resource ID from an HTTP request.
409 fn extract_id(&self, request: &Request<B>) -> IdExtractionResult;
410}
411
412// Implement for Arc<T> where T: IdExtractor
413impl<B, T: IdExtractor<B>> IdExtractor<B> for Arc<T> {
414 fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
415 (**self).extract_id(request)
416 }
417}
418
419// Implement for Box<T> where T: IdExtractor
420impl<B, T: IdExtractor<B> + ?Sized> IdExtractor<B> for Box<T> {
421 fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
422 (**self).extract_id(request)
423 }
424}
425
426/// Extract ID from an HTTP header.
427///
428/// # Example
429/// ```
430/// use axum_acl::HeaderIdExtractor;
431///
432/// // Extract user ID from X-User-Id header
433/// let extractor = HeaderIdExtractor::new("X-User-Id");
434/// ```
435#[derive(Debug, Clone)]
436pub struct HeaderIdExtractor {
437 header_name: String,
438}
439
440impl HeaderIdExtractor {
441 /// Create a new header ID extractor.
442 pub fn new(header_name: impl Into<String>) -> Self {
443 Self {
444 header_name: header_name.into(),
445 }
446 }
447}
448
449impl<B> IdExtractor<B> for HeaderIdExtractor {
450 fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
451 match request.headers().get(&self.header_name) {
452 Some(value) => match value.to_str() {
453 Ok(s) if !s.is_empty() => IdExtractionResult::Id(s.trim().to_string()),
454 _ => IdExtractionResult::Anonymous,
455 },
456 None => IdExtractionResult::Anonymous,
457 }
458 }
459}
460
461/// Extract ID from a request extension.
462///
463/// This extractor looks for an ID that was set by a previous middleware
464/// (e.g., an authentication middleware) as a request extension.
465///
466/// # Example
467/// ```
468/// use axum_acl::ExtensionIdExtractor;
469///
470/// // The authentication middleware should insert a User struct into extensions
471/// #[derive(Clone)]
472/// struct AuthenticatedUser {
473/// id: String,
474/// name: String,
475/// }
476///
477/// let extractor = ExtensionIdExtractor::<AuthenticatedUser>::new(|user| user.id.clone());
478/// ```
479pub struct ExtensionIdExtractor<T> {
480 extract_fn: Box<dyn Fn(&T) -> String + Send + Sync>,
481}
482
483impl<T> ExtensionIdExtractor<T> {
484 /// Create a new extension ID extractor.
485 ///
486 /// The `extract_fn` converts the extension type to an ID string.
487 pub fn new<F>(extract_fn: F) -> Self
488 where
489 F: Fn(&T) -> String + Send + Sync + 'static,
490 {
491 Self {
492 extract_fn: Box::new(extract_fn),
493 }
494 }
495}
496
497impl<T> std::fmt::Debug for ExtensionIdExtractor<T> {
498 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499 f.debug_struct("ExtensionIdExtractor")
500 .field("type", &std::any::type_name::<T>())
501 .finish()
502 }
503}
504
505impl<B, T: Clone + Send + Sync + 'static> IdExtractor<B> for ExtensionIdExtractor<T> {
506 fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
507 match request.extensions().get::<T>() {
508 Some(ext) => IdExtractionResult::Id((self.extract_fn)(ext)),
509 None => IdExtractionResult::Anonymous,
510 }
511 }
512}
513
514/// An ID extractor that always returns a fixed ID.
515///
516/// Useful for testing.
517#[derive(Debug, Clone)]
518pub struct FixedIdExtractor {
519 id: String,
520}
521
522impl FixedIdExtractor {
523 /// Create a new fixed ID extractor.
524 pub fn new(id: impl Into<String>) -> Self {
525 Self { id: id.into() }
526 }
527}
528
529impl<B> IdExtractor<B> for FixedIdExtractor {
530 fn extract_id(&self, _request: &Request<B>) -> IdExtractionResult {
531 IdExtractionResult::Id(self.id.clone())
532 }
533}
534
535/// An ID extractor that always returns anonymous (no ID).
536#[derive(Debug, Clone, Default)]
537pub struct AnonymousIdExtractor;
538
539impl AnonymousIdExtractor {
540 /// Create a new anonymous ID extractor.
541 pub fn new() -> Self {
542 Self
543 }
544}
545
546impl<B> IdExtractor<B> for AnonymousIdExtractor {
547 fn extract_id(&self, _request: &Request<B>) -> IdExtractionResult {
548 IdExtractionResult::Anonymous
549 }
550}
551
552// ============================================================================
553// Generic Auth Extraction
554// ============================================================================
555
556/// Result of generic auth extraction.
557#[derive(Debug, Clone)]
558pub enum AuthResult<A> {
559 /// Auth context was successfully extracted.
560 Auth(A),
561 /// No auth could be extracted (anonymous/guest).
562 Anonymous,
563 /// An error occurred during extraction.
564 Error(String),
565}
566
567/// Trait for extracting a generic auth context from HTTP requests.
568///
569/// `A` is the auth type your ACL rules use.
570/// `B` is the request body type.
571pub trait AuthExtractor<A, B>: Send + Sync {
572 /// Extract auth context from the request.
573 fn extract_auth(&self, request: &Request<B>) -> AuthResult<A>;
574}
575
576/// Adapter that combines a `RoleExtractor` and `IdExtractor` into
577/// an `AuthExtractor<BitmaskAuth, B>`.
578pub struct BitmaskAuthExtractor<E, I> {
579 role_extractor: E,
580 id_extractor: I,
581 anonymous_roles: u32,
582 default_id: String,
583}
584
585impl<E, I> BitmaskAuthExtractor<E, I> {
586 /// Create a new adapter from existing extractors.
587 pub fn new(role_extractor: E, id_extractor: I) -> Self {
588 Self {
589 role_extractor,
590 id_extractor,
591 anonymous_roles: 0,
592 default_id: "*".to_string(),
593 }
594 }
595
596 /// Set the roles bitmask to use for anonymous users.
597 pub fn with_anonymous_roles(mut self, roles: u32) -> Self {
598 self.anonymous_roles = roles;
599 self
600 }
601
602 /// Set the default ID when the ID extractor returns anonymous.
603 pub fn with_default_id(mut self, id: impl Into<String>) -> Self {
604 self.default_id = id.into();
605 self
606 }
607}
608
609impl<E: std::fmt::Debug, I: std::fmt::Debug> std::fmt::Debug for BitmaskAuthExtractor<E, I> {
610 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611 f.debug_struct("BitmaskAuthExtractor")
612 .field("role_extractor", &self.role_extractor)
613 .field("id_extractor", &self.id_extractor)
614 .finish()
615 }
616}
617
618impl<E, I, B> AuthExtractor<BitmaskAuth, B> for BitmaskAuthExtractor<E, I>
619where
620 E: RoleExtractor<B>,
621 I: IdExtractor<B>,
622{
623 fn extract_auth(&self, request: &Request<B>) -> AuthResult<BitmaskAuth> {
624 let roles = self.role_extractor.extract_roles(request).roles_or(self.anonymous_roles);
625 let id = self.id_extractor.extract_id(request).id_or(&self.default_id);
626 AuthResult::Auth(BitmaskAuth { roles, id })
627 }
628}
629
630impl<B> ChainedRoleExtractor<B> {
631 /// Create a new chained role extractor.
632 pub fn new() -> Self {
633 Self {
634 extractors: Vec::new(),
635 }
636 }
637
638 /// Add an extractor to the chain.
639 pub fn push<E: RoleExtractor<B> + 'static>(mut self, extractor: E) -> Self {
640 self.extractors.push(Box::new(extractor));
641 self
642 }
643}
644
645impl<B> Default for ChainedRoleExtractor<B> {
646 fn default() -> Self {
647 Self::new()
648 }
649}
650
651impl<B> std::fmt::Debug for ChainedRoleExtractor<B> {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 f.debug_struct("ChainedRoleExtractor")
654 .field("extractors_count", &self.extractors.len())
655 .finish()
656 }
657}
658
659impl<B> RoleExtractor<B> for ChainedRoleExtractor<B>
660where
661 B: Send + Sync,
662{
663 fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
664 for extractor in &self.extractors {
665 match extractor.extract_roles(request) {
666 RoleExtractionResult::Roles(roles) => return RoleExtractionResult::Roles(roles),
667 RoleExtractionResult::Error(e) => {
668 tracing::warn!(error = %e, "Role extractor failed, trying next");
669 }
670 RoleExtractionResult::Anonymous => continue,
671 }
672 }
673 RoleExtractionResult::Anonymous
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use http::Request;
681
682 #[test]
683 fn test_header_extractor_decimal() {
684 let extractor = HeaderRoleExtractor::new("X-Roles");
685
686 let req = Request::builder()
687 .header("X-Roles", "5") // 0b101 = roles 0 and 2
688 .body(())
689 .unwrap();
690
691 match extractor.extract_roles(&req) {
692 RoleExtractionResult::Roles(roles) => assert_eq!(roles, 5),
693 _ => panic!("Expected Roles"),
694 }
695 }
696
697 #[test]
698 fn test_header_extractor_hex() {
699 let extractor = HeaderRoleExtractor::new("X-Roles");
700
701 let req = Request::builder()
702 .header("X-Roles", "0x1F") // 0b11111 = roles 0-4
703 .body(())
704 .unwrap();
705
706 match extractor.extract_roles(&req) {
707 RoleExtractionResult::Roles(roles) => assert_eq!(roles, 0x1F),
708 _ => panic!("Expected Roles"),
709 }
710 }
711
712 #[test]
713 fn test_header_extractor_missing() {
714 let extractor = HeaderRoleExtractor::new("X-Roles");
715
716 let req = Request::builder().body(()).unwrap();
717
718 match extractor.extract_roles(&req) {
719 RoleExtractionResult::Anonymous => {}
720 _ => panic!("Expected Anonymous"),
721 }
722 }
723
724 #[test]
725 fn test_header_extractor_default() {
726 let extractor = HeaderRoleExtractor::new("X-Roles")
727 .with_default_roles(0b100); // guest role
728
729 let req = Request::builder().body(()).unwrap();
730
731 match extractor.extract_roles(&req) {
732 RoleExtractionResult::Roles(roles) => assert_eq!(roles, 0b100),
733 _ => panic!("Expected Roles"),
734 }
735 }
736
737 #[test]
738 fn test_fixed_extractor() {
739 let extractor = FixedRoleExtractor::new(0b11); // admin + user
740
741 let req = Request::builder().body(()).unwrap();
742
743 match extractor.extract_roles(&req) {
744 RoleExtractionResult::Roles(roles) => assert_eq!(roles, 0b11),
745 _ => panic!("Expected Roles"),
746 }
747 }
748}