Skip to main content

axum_acl/
table.rs

1//! ACL table for storing and evaluating rules.
2//!
3//! The [`AclTable`] is the central data structure that holds all ACL rules
4//! and provides methods to evaluate requests against them.
5//!
6//! Uses a HashMap for O(1) endpoint lookup, with filters for role/time/ip/id matching.
7
8use crate::rule::{
9    AclAction, AclRuleFilter, BitmaskAuth, EndpointPattern, RequestContext, RequestMeta,
10    RuleMatcher,
11};
12use std::collections::HashMap;
13use std::sync::Arc;
14
15/// A single rule in the generic ACL table, wrapping a `RuleMatcher<A>`.
16pub struct AclRule<A> {
17    pub(crate) matcher: Arc<dyn RuleMatcher<A>>,
18}
19
20impl<A> Clone for AclRule<A> {
21    fn clone(&self) -> Self {
22        Self {
23            matcher: self.matcher.clone(),
24        }
25    }
26}
27
28impl<A> std::fmt::Debug for AclRule<A> {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("AclRule")
31            .field("matcher", &self.matcher)
32            .finish()
33    }
34}
35
36impl<A> AclRule<A> {
37    /// Create a rule from a matcher.
38    pub fn from_matcher(matcher: Arc<dyn RuleMatcher<A>>) -> Self {
39        Self { matcher }
40    }
41
42    /// Create a rule with HTTP method filtering.
43    pub fn from_matcher_with_methods(
44        matcher: Arc<dyn RuleMatcher<A>>,
45        methods: Vec<http::Method>,
46        action: AclAction,
47    ) -> Self
48    where
49        A: Send + Sync + 'static,
50    {
51        Self {
52            matcher: Arc::new(MethodFilterMatcher {
53                inner: matcher,
54                methods,
55                action,
56            }),
57        }
58    }
59
60    /// Get the action this rule takes when matched.
61    pub fn action(&self) -> &AclAction {
62        self.matcher.action()
63    }
64
65    /// Get the description of this rule.
66    pub fn description(&self) -> Option<&str> {
67        self.matcher.description()
68    }
69}
70
71/// Wrapper matcher that adds HTTP method filtering to another matcher.
72struct MethodFilterMatcher<A> {
73    inner: Arc<dyn RuleMatcher<A>>,
74    methods: Vec<http::Method>,
75    action: AclAction,
76}
77
78impl<A> std::fmt::Debug for MethodFilterMatcher<A> {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("MethodFilterMatcher")
81            .field("methods", &self.methods)
82            .field("inner", &self.inner)
83            .finish()
84    }
85}
86
87impl<A> RuleMatcher<A> for MethodFilterMatcher<A>
88where
89    A: Send + Sync,
90{
91    fn matches(&self, auth: &A, meta: &RequestMeta) -> bool {
92        (self.methods.is_empty() || self.methods.contains(&meta.method))
93            && self.inner.matches(auth, meta)
94    }
95
96    fn action(&self) -> &AclAction {
97        &self.action
98    }
99
100    fn description(&self) -> Option<&str> {
101        self.inner.description()
102    }
103}
104
105/// A table containing ACL rules for evaluation.
106///
107/// Uses a 5-tuple system: (endpoint, role, time, ip, id)
108/// - Endpoint is used as HashMap key for O(1) lookup
109/// - Role, time, ip, id are filters applied after endpoint match
110///
111/// The type parameter `A` is the auth context type. Defaults to `BitmaskAuth`
112/// for backward compatibility with the u32 bitmask role system.
113///
114/// # Example
115/// ```
116/// use axum_acl::{AclTable, AclRuleFilter, AclAction};
117///
118/// let table = AclTable::builder()
119///     .default_action(AclAction::Deny)
120///     // Exact endpoint match
121///     .add_exact("/api/users", AclRuleFilter::new()
122///         .role_mask(0b11)  // roles 0 and 1
123///         .action(AclAction::Allow))
124///     // Prefix match for /admin/*
125///     .add_prefix("/admin/", AclRuleFilter::new()
126///         .role_mask(0b1)   // role 0 only (admin)
127///         .action(AclAction::Allow))
128///     .build();
129/// ```
130pub struct AclTable<A = BitmaskAuth> {
131    /// O(1) lookup for exact endpoint matches.
132    pub(crate) exact_rules: HashMap<String, Vec<AclRule<A>>>,
133    /// Fallback for prefix/glob/any patterns (checked in order).
134    pub(crate) pattern_rules: Vec<(EndpointPattern, Vec<AclRule<A>>)>,
135    /// Default action when no rules match.
136    pub(crate) default_action: AclAction,
137}
138
139impl<A> Clone for AclTable<A> {
140    fn clone(&self) -> Self {
141        Self {
142            exact_rules: self.exact_rules.clone(),
143            pattern_rules: self.pattern_rules.clone(),
144            default_action: self.default_action.clone(),
145        }
146    }
147}
148
149impl<A> std::fmt::Debug for AclTable<A> {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("AclTable")
152            .field("exact_rules_count", &self.exact_rules.len())
153            .field("pattern_rules_count", &self.pattern_rules.len())
154            .field("default_action", &self.default_action)
155            .finish()
156    }
157}
158
159impl<A> Default for AclTable<A> {
160    fn default() -> Self {
161        Self {
162            exact_rules: HashMap::new(),
163            pattern_rules: Vec::new(),
164            default_action: AclAction::Deny,
165        }
166    }
167}
168
169// Generic methods available for all auth types.
170impl<A> AclTable<A> {
171    /// Get the exact rules map.
172    pub fn exact_rules(&self) -> &HashMap<String, Vec<AclRule<A>>> {
173        &self.exact_rules
174    }
175
176    /// Get the pattern rules.
177    pub fn pattern_rules(&self) -> &[(EndpointPattern, Vec<AclRule<A>>)] {
178        &self.pattern_rules
179    }
180
181    /// Get the default action when no rules match.
182    pub fn default_action(&self) -> AclAction {
183        self.default_action.clone()
184    }
185
186    /// Evaluate ACL rules using the generic auth type and request metadata.
187    ///
188    /// During pattern rule evaluation, named path parameters are extracted
189    /// from the matching pattern and set on `meta.path_params`.
190    pub fn evaluate_request(&self, auth: &A, meta: &RequestMeta) -> AclAction {
191        self.evaluate_request_with_match(auth, meta).0
192    }
193
194    /// Evaluate ACL rules and return both the action and match info.
195    pub fn evaluate_request_with_match(
196        &self,
197        auth: &A,
198        meta: &RequestMeta,
199    ) -> (AclAction, Option<(String, usize)>) {
200        // 1. Try exact endpoint match first (O(1))
201        if let Some(rules) = self.exact_rules.get(&meta.path) {
202            for (idx, rule) in rules.iter().enumerate() {
203                if rule.matcher.matches(auth, meta) {
204                    tracing::debug!(
205                        endpoint = %meta.path,
206                        filter_index = idx,
207                        filter_description = ?rule.description(),
208                        ip = %meta.ip,
209                        action = ?rule.action(),
210                        "ACL exact match"
211                    );
212                    return (rule.action().clone(), Some((meta.path.clone(), idx)));
213                }
214            }
215        }
216
217        // 2. Try pattern rules (prefix/glob/any)
218        for (pattern, rules) in &self.pattern_rules {
219            if pattern.matches(&meta.path) {
220                let mut meta_with_params = meta.clone();
221                meta_with_params.path_params = pattern.extract_named_params(&meta.path);
222
223                for (idx, rule) in rules.iter().enumerate() {
224                    if rule.matcher.matches(auth, &meta_with_params) {
225                        tracing::debug!(
226                            endpoint = ?pattern,
227                            filter_index = idx,
228                            filter_description = ?rule.description(),
229                            ip = %meta.ip,
230                            action = ?rule.action(),
231                            "ACL pattern match"
232                        );
233                        return (
234                            rule.action().clone(),
235                            Some((format!("{:?}", pattern), idx)),
236                        );
237                    }
238                }
239            }
240        }
241
242        tracing::debug!(
243            path = %meta.path,
244            ip = %meta.ip,
245            action = ?self.default_action,
246            "No ACL rule matched, using default action"
247        );
248        (self.default_action.clone(), None)
249    }
250
251    /// Check if access is allowed using the generic auth type.
252    pub fn is_request_allowed(&self, auth: &A, meta: &RequestMeta) -> bool {
253        self.evaluate_request(auth, meta) == AclAction::Allow
254    }
255}
256
257// Backward-compatible methods for BitmaskAuth (the default).
258impl AclTable<BitmaskAuth> {
259    /// Create a new empty ACL table with deny as default action.
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// Create a builder for constructing an ACL table.
265    pub fn builder() -> AclTableBuilder<BitmaskAuth> {
266        AclTableBuilder::new()
267    }
268
269    /// Evaluate the ACL rules for a given request context.
270    ///
271    /// Lookup order:
272    /// 1. Exact endpoint match in HashMap (O(1))
273    /// 2. Pattern rules (prefix/glob/any) in order
274    ///
275    /// For each endpoint match, filters are checked: id → roles → ip → time
276    ///
277    /// # Example
278    /// ```
279    /// use axum_acl::{AclTable, AclRuleFilter, AclAction, RequestContext};
280    /// use std::net::IpAddr;
281    ///
282    /// let table = AclTable::builder()
283    ///     .add_exact("/api/users", AclRuleFilter::new()
284    ///         .role_mask(0b11)
285    ///         .action(AclAction::Allow))
286    ///     .build();
287    ///
288    /// let ip: IpAddr = "127.0.0.1".parse().unwrap();
289    /// let ctx = RequestContext::new(0b01, ip, "user123");
290    /// let action = table.evaluate("/api/users", &ctx);
291    /// assert_eq!(action, AclAction::Allow);
292    /// ```
293    pub fn evaluate(&self, path: &str, ctx: &RequestContext) -> AclAction {
294        self.evaluate_with_match(path, ctx).0
295    }
296
297    /// Evaluate the ACL rules and return both the action and match info.
298    ///
299    /// Returns `(action, Some((endpoint, filter_index)))` if matched,
300    /// or `(default_action, None)` if no rules matched.
301    pub fn evaluate_with_match(
302        &self,
303        path: &str,
304        ctx: &RequestContext,
305    ) -> (AclAction, Option<(String, usize)>) {
306        let meta = RequestMeta {
307            method: http::Method::GET,
308            path: path.to_string(),
309            path_params: HashMap::new(),
310            ip: ctx.ip,
311        };
312        let auth = BitmaskAuth {
313            roles: ctx.roles,
314            id: ctx.id.to_string(),
315        };
316        self.evaluate_request_with_match(&auth, &meta)
317    }
318
319    /// Check if access is allowed for the given context.
320    pub fn is_allowed(&self, path: &str, ctx: &RequestContext) -> bool {
321        self.evaluate(path, ctx) == AclAction::Allow
322    }
323}
324
325/// Builder for constructing an [`AclTable`].
326pub struct AclTableBuilder<A = BitmaskAuth> {
327    exact_rules: HashMap<String, Vec<AclRule<A>>>,
328    pattern_rules: Vec<(EndpointPattern, Vec<AclRule<A>>)>,
329    default_action: AclAction,
330}
331
332impl<A> Default for AclTableBuilder<A> {
333    fn default() -> Self {
334        Self {
335            exact_rules: HashMap::new(),
336            pattern_rules: Vec::new(),
337            default_action: AclAction::Deny,
338        }
339    }
340}
341
342impl<A> std::fmt::Debug for AclTableBuilder<A> {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.debug_struct("AclTableBuilder")
345            .field("default_action", &self.default_action)
346            .finish()
347    }
348}
349
350// Generic builder methods for any auth type.
351impl<A: 'static> AclTableBuilder<A> {
352    /// Create a new builder.
353    pub fn new() -> Self {
354        Self::default()
355    }
356
357    /// Set the default action when no rules match.
358    ///
359    /// The default is `AclAction::Deny`.
360    pub fn default_action(mut self, action: AclAction) -> Self {
361        self.default_action = action;
362        self
363    }
364
365    /// Add a matcher for an exact endpoint match (O(1) lookup).
366    pub fn add_exact_matcher(
367        mut self,
368        endpoint: impl Into<String>,
369        matcher: impl RuleMatcher<A> + 'static,
370    ) -> Self {
371        let rule = AclRule {
372            matcher: Arc::new(matcher),
373        };
374        self.exact_rules.entry(endpoint.into()).or_default().push(rule);
375        self
376    }
377
378    /// Add a matcher for a pattern endpoint match.
379    pub fn add_pattern_matcher(
380        mut self,
381        pattern: EndpointPattern,
382        matcher: impl RuleMatcher<A> + 'static,
383    ) -> Self {
384        let rule = AclRule {
385            matcher: Arc::new(matcher),
386        };
387        for (existing_pattern, rules) in &mut self.pattern_rules {
388            let is_match = match (existing_pattern, &pattern) {
389                (EndpointPattern::Any, EndpointPattern::Any) => true,
390                (EndpointPattern::Prefix(a), EndpointPattern::Prefix(b)) => a == b,
391                (EndpointPattern::Glob(a), EndpointPattern::Glob(b)) => a == b,
392                (EndpointPattern::Exact(a), EndpointPattern::Exact(b)) => a == b,
393                _ => false,
394            };
395            if is_match {
396                rules.push(rule);
397                return self;
398            }
399        }
400        self.pattern_rules.push((pattern, vec![rule]));
401        self
402    }
403
404    /// Add a matcher that applies to any endpoint.
405    pub fn add_any_matcher(self, matcher: impl RuleMatcher<A> + 'static) -> Self {
406        self.add_pattern_matcher(EndpointPattern::Any, matcher)
407    }
408
409    /// Build the ACL table.
410    pub fn build(self) -> AclTable<A> {
411        AclTable {
412            exact_rules: self.exact_rules,
413            pattern_rules: self.pattern_rules,
414            default_action: self.default_action,
415        }
416    }
417
418    /// Build the ACL table wrapped in an Arc for sharing.
419    pub fn build_shared(self) -> Arc<AclTable<A>> {
420        Arc::new(self.build())
421    }
422}
423
424// Backward-compatible builder methods for BitmaskAuth.
425impl AclTableBuilder<BitmaskAuth> {
426    /// Add a filter for an exact endpoint match (O(1) lookup).
427    pub fn add_exact(self, endpoint: impl Into<String>, filter: AclRuleFilter) -> Self {
428        self.add_exact_matcher(endpoint, filter)
429    }
430
431    /// Add multiple filters for an exact endpoint.
432    pub fn add_exact_filters(
433        mut self,
434        endpoint: impl Into<String>,
435        filters: impl IntoIterator<Item = AclRuleFilter>,
436    ) -> Self {
437        let endpoint = endpoint.into();
438        let rules: Vec<AclRule<BitmaskAuth>> = filters
439            .into_iter()
440            .map(|f| AclRule {
441                matcher: Arc::new(f),
442            })
443            .collect();
444        self.exact_rules.entry(endpoint).or_default().extend(rules);
445        self
446    }
447
448    /// Add a filter for a prefix endpoint match.
449    pub fn add_prefix(self, prefix: impl Into<String>, filter: AclRuleFilter) -> Self {
450        let pattern = EndpointPattern::Prefix(prefix.into());
451        self.add_pattern_matcher(pattern, filter)
452    }
453
454    /// Add a filter for a glob endpoint match.
455    pub fn add_glob(self, glob: impl Into<String>, filter: AclRuleFilter) -> Self {
456        let pattern = EndpointPattern::Glob(glob.into());
457        self.add_pattern_matcher(pattern, filter)
458    }
459
460    /// Add a filter that matches any endpoint.
461    pub fn add_any(self, filter: AclRuleFilter) -> Self {
462        self.add_pattern_matcher(EndpointPattern::Any, filter)
463    }
464
465    /// Add a filter for a custom endpoint pattern.
466    pub fn add_pattern(self, pattern: EndpointPattern, filter: AclRuleFilter) -> Self {
467        self.add_pattern_matcher(pattern, filter)
468    }
469}
470
471/// Rule entry for providers: endpoint pattern + filter.
472#[derive(Debug, Clone)]
473pub struct RuleEntry {
474    /// The endpoint pattern.
475    pub pattern: EndpointPattern,
476    /// The filter for this endpoint.
477    pub filter: AclRuleFilter,
478}
479
480impl RuleEntry {
481    /// Create a new rule entry.
482    pub fn new(pattern: EndpointPattern, filter: AclRuleFilter) -> Self {
483        Self { pattern, filter }
484    }
485
486    /// Create an exact endpoint rule.
487    pub fn exact(endpoint: impl Into<String>, filter: AclRuleFilter) -> Self {
488        Self::new(EndpointPattern::Exact(endpoint.into()), filter)
489    }
490
491    /// Create a prefix endpoint rule.
492    pub fn prefix(prefix: impl Into<String>, filter: AclRuleFilter) -> Self {
493        Self::new(EndpointPattern::Prefix(prefix.into()), filter)
494    }
495
496    /// Create a glob endpoint rule.
497    pub fn glob(glob: impl Into<String>, filter: AclRuleFilter) -> Self {
498        Self::new(EndpointPattern::Glob(glob.into()), filter)
499    }
500
501    /// Create an any endpoint rule.
502    pub fn any(filter: AclRuleFilter) -> Self {
503        Self::new(EndpointPattern::Any, filter)
504    }
505}
506
507/// Trait for types that can provide ACL rules.
508///
509/// Implement this trait to load rules from external sources like databases,
510/// configuration files, or remote services.
511///
512/// # Example
513/// ```
514/// use axum_acl::{AclRuleProvider, RuleEntry, AclRuleFilter, AclAction, EndpointPattern};
515///
516/// struct ConfigRuleProvider {
517///     config_path: String,
518/// }
519///
520/// impl AclRuleProvider for ConfigRuleProvider {
521///     type Error = std::io::Error;
522///
523///     fn load_rules(&self) -> Result<Vec<RuleEntry>, Self::Error> {
524///         // Load rules from config file
525///         Ok(vec![
526///             RuleEntry::any(AclRuleFilter::new()
527///                 .role_mask(0b1)  // admin role
528///                 .action(AclAction::Allow))
529///         ])
530///     }
531/// }
532/// ```
533pub trait AclRuleProvider: Send + Sync {
534    /// Error type for rule loading failures.
535    type Error: std::error::Error + Send + Sync + 'static;
536
537    /// Load rules from the provider.
538    fn load_rules(&self) -> Result<Vec<RuleEntry>, Self::Error>;
539}
540
541/// A simple rule provider that returns a static list of rules.
542#[derive(Debug, Clone)]
543pub struct StaticRuleProvider {
544    rules: Vec<RuleEntry>,
545}
546
547impl StaticRuleProvider {
548    /// Create a new static rule provider.
549    pub fn new(rules: Vec<RuleEntry>) -> Self {
550        Self { rules }
551    }
552}
553
554impl AclRuleProvider for StaticRuleProvider {
555    type Error = std::convert::Infallible;
556
557    fn load_rules(&self) -> Result<Vec<RuleEntry>, Self::Error> {
558        Ok(self.rules.clone())
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use std::net::IpAddr;
566
567    const ROLE_ADMIN: u32 = 0b001;
568    const ROLE_USER: u32 = 0b010;
569    const ROLE_GUEST: u32 = 0b100;
570
571    #[test]
572    fn test_table_evaluation() {
573        let table = AclTable::builder()
574            .default_action(AclAction::Deny)
575            // Admin can access anything
576            .add_any(
577                AclRuleFilter::new()
578                    .role_mask(ROLE_ADMIN)
579                    .action(AclAction::Allow),
580            )
581            // User can access /api/
582            .add_prefix(
583                "/api/",
584                AclRuleFilter::new()
585                    .role_mask(ROLE_USER)
586                    .action(AclAction::Allow),
587            )
588            .build();
589
590        let ip: IpAddr = "127.0.0.1".parse().unwrap();
591
592        // Admin can access anything
593        let admin_ctx = RequestContext::new(ROLE_ADMIN, ip, "admin1");
594        assert!(table.is_allowed("/admin/dashboard", &admin_ctx));
595        assert!(table.is_allowed("/api/users", &admin_ctx));
596
597        // User can only access /api/
598        let user_ctx = RequestContext::new(ROLE_USER, ip, "user1");
599        assert!(table.is_allowed("/api/users", &user_ctx));
600        assert!(!table.is_allowed("/admin/dashboard", &user_ctx));
601
602        // Guest is denied (default action)
603        let guest_ctx = RequestContext::new(ROLE_GUEST, ip, "guest1");
604        assert!(!table.is_allowed("/api/users", &guest_ctx));
605    }
606
607    #[test]
608    fn test_exact_before_pattern() {
609        // Exact match takes priority over patterns
610        let table = AclTable::builder()
611            .default_action(AclAction::Deny)
612            // Exact match for /public
613            .add_exact(
614                "/public",
615                AclRuleFilter::new()
616                    .role_mask(u32::MAX)
617                    .action(AclAction::Allow),
618            )
619            // Deny everything else
620            .add_any(
621                AclRuleFilter::new()
622                    .role_mask(u32::MAX)
623                    .action(AclAction::Deny),
624            )
625            .build();
626
627        let ip: IpAddr = "127.0.0.1".parse().unwrap();
628        let ctx = RequestContext::new(0b1, ip, "anyone");
629
630        assert!(table.is_allowed("/public", &ctx));
631        assert!(!table.is_allowed("/private", &ctx));
632    }
633
634    #[test]
635    fn test_role_bitmask() {
636        let table = AclTable::builder()
637            .default_action(AclAction::Deny)
638            .add_exact(
639                "/shared",
640                AclRuleFilter::new()
641                    .role_mask(ROLE_ADMIN | ROLE_USER) // admin OR user
642                    .action(AclAction::Allow),
643            )
644            .build();
645
646        let ip: IpAddr = "127.0.0.1".parse().unwrap();
647
648        // Admin can access
649        assert!(table.is_allowed("/shared", &RequestContext::new(ROLE_ADMIN, ip, "a")));
650        // User can access
651        assert!(table.is_allowed("/shared", &RequestContext::new(ROLE_USER, ip, "u")));
652        // Guest cannot
653        assert!(!table.is_allowed("/shared", &RequestContext::new(ROLE_GUEST, ip, "g")));
654        // User+Admin can access (has overlap)
655        assert!(table.is_allowed(
656            "/shared",
657            &RequestContext::new(ROLE_ADMIN | ROLE_USER, ip, "au")
658        ));
659    }
660
661    #[test]
662    fn test_generic_table_custom_auth() {
663        #[derive(Debug, Clone)]
664        struct CustomAuth {
665            role: String,
666        }
667
668        #[derive(Debug)]
669        struct RequireRole {
670            role: String,
671            action: AclAction,
672        }
673
674        impl RuleMatcher<CustomAuth> for RequireRole {
675            fn matches(&self, auth: &CustomAuth, _meta: &RequestMeta) -> bool {
676                auth.role == self.role
677            }
678            fn action(&self) -> &AclAction {
679                &self.action
680            }
681        }
682
683        let table: AclTable<CustomAuth> = AclTableBuilder::new()
684            .default_action(AclAction::Deny)
685            .add_exact_matcher(
686                "/admin",
687                RequireRole {
688                    role: "admin".to_string(),
689                    action: AclAction::Allow,
690                },
691            )
692            .build();
693
694        let ip: IpAddr = "127.0.0.1".parse().unwrap();
695        let meta = RequestMeta {
696            method: http::Method::GET,
697            path: "/admin".to_string(),
698            path_params: HashMap::new(),
699            ip,
700        };
701
702        let admin = CustomAuth {
703            role: "admin".to_string(),
704        };
705        assert!(table.is_request_allowed(&admin, &meta));
706
707        let user = CustomAuth {
708            role: "user".to_string(),
709        };
710        assert!(!table.is_request_allowed(&user, &meta));
711    }
712}