Skip to main content

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}