Skip to main content

axum_acl/
rule.rs

1//! ACL rule definitions and matching logic.
2//!
3//! This module provides the core [`AclRuleFilter`] struct that defines access control rules
4//! using a 5-tuple: (endpoint, role, time, ip, id).
5//!
6//! - **Endpoint**: Used as HashMap key for O(1) lookup
7//! - **Role**: `u32` bitmask for efficient role matching (up to 32 roles)
8//! - **Time**: Time window filter (start < now < end)
9//! - **IP**: IP address/CIDR filter (ip & mask == network)
10//! - **ID**: Exact match or "*" wildcard
11
12use chrono::{Datelike, NaiveTime, Utc};
13use http::Method;
14use ipnetwork::IpNetwork;
15use std::collections::HashMap;
16use std::net::IpAddr;
17
18/// Request context for ACL evaluation.
19#[derive(Debug, Clone)]
20pub struct RequestContext<'a> {
21    /// User's role bitmask (up to 32 roles).
22    pub roles: u32,
23    /// Client IP address.
24    pub ip: IpAddr,
25    /// User/session ID.
26    pub id: &'a str,
27}
28
29impl<'a> RequestContext<'a> {
30    /// Create a new request context.
31    pub fn new(roles: u32, ip: IpAddr, id: &'a str) -> Self {
32        Self { roles, ip, id }
33    }
34}
35
36/// The legacy bitmask-based auth context.
37///
38/// Wraps the old `(roles: u32, id: String)` pair for use with the generic
39/// `RuleMatcher<A>` system. `AclRuleFilter` implements `RuleMatcher<BitmaskAuth>`.
40#[derive(Debug, Clone)]
41pub struct BitmaskAuth {
42    /// Role bitmask (u32).
43    pub roles: u32,
44    /// User/session ID.
45    pub id: String,
46}
47
48/// Metadata about the incoming HTTP request, available to all matchers.
49#[derive(Debug, Clone)]
50pub struct RequestMeta {
51    /// HTTP method (GET, POST, etc.)
52    pub method: Method,
53    /// Request path.
54    pub path: String,
55    /// Named path parameters extracted from the matching `EndpointPattern`.
56    pub path_params: HashMap<String, String>,
57    /// Client IP address.
58    pub ip: IpAddr,
59}
60
61/// Trait for matching a rule against an auth context and request metadata.
62///
63/// `A` is the application-specific auth type (e.g., `BitmaskAuth`, or a custom
64/// scoped-role struct). Implementations decide how to check authorization.
65pub trait RuleMatcher<A>: Send + Sync + std::fmt::Debug {
66    /// Check if this matcher allows the given auth context for the request.
67    fn matches(&self, auth: &A, meta: &RequestMeta) -> bool;
68
69    /// The action to take if this matcher matches.
70    fn action(&self) -> &AclAction;
71
72    /// Optional human-readable description for logging.
73    fn description(&self) -> Option<&str> {
74        None
75    }
76}
77
78/// Action to take when a rule matches.
79#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub enum AclAction {
81    /// Allow the request to proceed.
82    #[default]
83    Allow,
84    /// Deny the request with 403 Forbidden.
85    Deny,
86    /// Return a custom error response.
87    Error {
88        /// HTTP status code (default: 403).
89        code: u16,
90        /// Custom error message.
91        message: Option<String>,
92    },
93    /// Reroute to a different path.
94    Reroute {
95        /// Target path to reroute to.
96        target: String,
97        /// Whether to preserve original path in X-Original-Path header.
98        preserve_path: bool,
99    },
100    /// Rate limit (placeholder - requires external state).
101    RateLimit {
102        /// Maximum requests per window.
103        max_requests: u32,
104        /// Window duration in seconds.
105        window_secs: u64,
106    },
107    /// Log and allow (for monitoring/auditing).
108    Log {
109        /// Log level: "trace", "debug", "info", "warn", "error".
110        level: String,
111        /// Custom log message.
112        message: Option<String>,
113    },
114}
115
116impl AclAction {
117    /// Create a deny action (alias for Deny variant).
118    pub fn deny() -> Self {
119        Self::Deny
120    }
121
122    /// Create an allow action (alias for Allow variant).
123    pub fn allow() -> Self {
124        Self::Allow
125    }
126
127    /// Create a custom error action.
128    pub fn error(code: u16, message: impl Into<Option<String>>) -> Self {
129        Self::Error {
130            code,
131            message: message.into(),
132        }
133    }
134
135    /// Create a reroute action.
136    pub fn reroute(target: impl Into<String>) -> Self {
137        Self::Reroute {
138            target: target.into(),
139            preserve_path: false,
140        }
141    }
142
143    /// Create a reroute action that preserves the original path.
144    pub fn reroute_with_preserve(target: impl Into<String>) -> Self {
145        Self::Reroute {
146            target: target.into(),
147            preserve_path: true,
148        }
149    }
150
151    /// Check if this action allows the request to proceed.
152    pub fn is_allow(&self) -> bool {
153        matches!(self, Self::Allow | Self::Log { .. })
154    }
155
156    /// Check if this action denies/blocks the request.
157    pub fn is_deny(&self) -> bool {
158        matches!(self, Self::Deny | Self::Error { .. })
159    }
160}
161
162/// ACL rule filter for the 5-tuple matching system.
163///
164/// Filters are applied after endpoint lookup (endpoint is the HashMap key).
165/// Match priority: id → roles → ip → time
166#[derive(Debug, Clone)]
167pub struct AclRuleFilter {
168    /// ID matcher: "*" for any, or exact match.
169    pub id: String,
170    /// Role bitmask: `(rule.role_mask & ctx.roles) != 0` to match.
171    pub role_mask: u32,
172    /// HTTP methods this rule applies to. Empty means all methods.
173    pub methods: Vec<Method>,
174    /// Time window: start < now < end.
175    pub time: TimeWindow,
176    /// IP matcher: CIDR-style matching.
177    pub ip: IpMatcher,
178    /// Action to take when this filter matches.
179    pub action: AclAction,
180    /// Optional description for logging/debugging.
181    pub description: Option<String>,
182}
183
184impl AclRuleFilter {
185    /// Create a new filter that matches any ID and all roles.
186    pub fn new() -> Self {
187        Self {
188            id: "*".to_string(),
189            role_mask: u32::MAX, // all roles
190            methods: Vec::new(),
191            time: TimeWindow::default(),
192            ip: IpMatcher::Any,
193            action: AclAction::Allow,
194            description: None,
195        }
196    }
197
198    /// Set the ID matcher (exact match or "*" for any).
199    pub fn id(mut self, id: impl Into<String>) -> Self {
200        self.id = id.into();
201        self
202    }
203
204    /// Set the role bitmask.
205    pub fn role_mask(mut self, mask: u32) -> Self {
206        self.role_mask = mask;
207        self
208    }
209
210    /// Set a single role bit.
211    pub fn role(mut self, role_id: u8) -> Self {
212        self.role_mask = 1 << role_id;
213        self
214    }
215
216    /// Add a role bit to the mask.
217    pub fn add_role(mut self, role_id: u8) -> Self {
218        self.role_mask |= 1 << role_id;
219        self
220    }
221
222    /// Set the HTTP methods this rule applies to. Empty means all methods.
223    pub fn methods(mut self, methods: Vec<Method>) -> Self {
224        self.methods = methods;
225        self
226    }
227
228    /// Add an HTTP method this rule applies to.
229    pub fn method(mut self, method: Method) -> Self {
230        self.methods.push(method);
231        self
232    }
233
234    /// Set the time window.
235    pub fn time(mut self, window: TimeWindow) -> Self {
236        self.time = window;
237        self
238    }
239
240    /// Set the IP matcher.
241    pub fn ip(mut self, matcher: IpMatcher) -> Self {
242        self.ip = matcher;
243        self
244    }
245
246    /// Set the action.
247    pub fn action(mut self, action: AclAction) -> Self {
248        self.action = action;
249        self
250    }
251
252    /// Set a description.
253    pub fn description(mut self, desc: impl Into<String>) -> Self {
254        self.description = Some(desc.into());
255        self
256    }
257
258    /// Check if this filter matches the given context.
259    ///
260    /// Match order: id → roles → ip → time
261    #[inline]
262    pub fn matches(&self, ctx: &RequestContext) -> bool {
263        // 1. ID match (exact or wildcard)
264        (self.id == "*" || self.id == ctx.id)
265            // 2. Role match (any bit overlap)
266            && (self.role_mask & ctx.roles) != 0
267            // 3. IP match
268            && self.ip.matches(&ctx.ip)
269            // 4. Time match
270            && self.time.matches_now()
271    }
272}
273
274impl Default for AclRuleFilter {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280impl RuleMatcher<BitmaskAuth> for AclRuleFilter {
281    fn matches(&self, auth: &BitmaskAuth, meta: &RequestMeta) -> bool {
282        (self.methods.is_empty() || self.methods.contains(&meta.method))
283            && (self.id == "*" || self.id == auth.id)
284            && (self.role_mask & auth.roles) != 0
285            && self.ip.matches(&meta.ip)
286            && self.time.matches_now()
287    }
288
289    fn action(&self) -> &AclAction {
290        &self.action
291    }
292
293    fn description(&self) -> Option<&str> {
294        self.description.as_deref()
295    }
296}
297
298/// Time window specification for rule matching.
299///
300/// Defines a time range during which a rule is active.
301/// Times are evaluated in UTC.
302#[derive(Debug, Clone, Default)]
303pub struct TimeWindow {
304    /// Start time (inclusive). None means from midnight.
305    pub start: Option<NaiveTime>,
306    /// End time (inclusive). None means until midnight.
307    pub end: Option<NaiveTime>,
308    /// Days of the week when this window is active (0 = Monday, 6 = Sunday).
309    /// Empty means all days.
310    pub days: Vec<u32>,
311}
312
313impl TimeWindow {
314    /// Create a time window that matches any time.
315    pub fn any() -> Self {
316        Self::default()
317    }
318
319    /// Create a time window for specific hours (24-hour format, UTC).
320    ///
321    /// # Example
322    /// ```
323    /// use axum_acl::TimeWindow;
324    ///
325    /// // Active from 9 AM to 5 PM UTC
326    /// let window = TimeWindow::hours(9, 17);
327    /// ```
328    pub fn hours(start_hour: u32, end_hour: u32) -> Self {
329        Self {
330            start: Some(NaiveTime::from_hms_opt(start_hour, 0, 0).unwrap_or_default()),
331            end: Some(NaiveTime::from_hms_opt(end_hour, 0, 0).unwrap_or_default()),
332            days: Vec::new(),
333        }
334    }
335
336    /// Create a time window for specific hours on specific days.
337    ///
338    /// # Arguments
339    /// * `start_hour` - Start hour (0-23)
340    /// * `end_hour` - End hour (0-23)
341    /// * `days` - Days of week (0 = Monday, 6 = Sunday)
342    ///
343    /// # Example
344    /// ```
345    /// use axum_acl::TimeWindow;
346    ///
347    /// // Active Mon-Fri 9 AM to 5 PM UTC
348    /// let window = TimeWindow::hours_on_days(9, 17, vec![0, 1, 2, 3, 4]);
349    /// ```
350    pub fn hours_on_days(start_hour: u32, end_hour: u32, days: Vec<u32>) -> Self {
351        Self {
352            start: Some(NaiveTime::from_hms_opt(start_hour, 0, 0).unwrap_or_default()),
353            end: Some(NaiveTime::from_hms_opt(end_hour, 0, 0).unwrap_or_default()),
354            days,
355        }
356    }
357
358    /// Check if the current time falls within this window.
359    pub fn matches_now(&self) -> bool {
360        let now = Utc::now();
361        let current_time = now.time();
362        let current_day = now.weekday().num_days_from_monday();
363
364        // Check day of week
365        if !self.days.is_empty() && !self.days.contains(&current_day) {
366            return false;
367        }
368
369        // Check time range
370        match (&self.start, &self.end) {
371            (Some(start), Some(end)) => {
372                if start <= end {
373                    // Normal range: 9:00 - 17:00
374                    current_time >= *start && current_time <= *end
375                } else {
376                    // Overnight range: 22:00 - 06:00
377                    current_time >= *start || current_time <= *end
378                }
379            }
380            (Some(start), None) => current_time >= *start,
381            (None, Some(end)) => current_time <= *end,
382            (None, None) => true,
383        }
384    }
385}
386
387/// IP address specification for rule matching.
388#[derive(Debug, Clone, Default)]
389pub enum IpMatcher {
390    /// Match any IP address.
391    #[default]
392    Any,
393    /// Match a single IP address.
394    Single(IpAddr),
395    /// Match an IP network (CIDR notation).
396    Network(IpNetwork),
397    /// Match multiple IP addresses or networks.
398    List(Vec<IpMatcher>),
399}
400
401impl IpMatcher {
402    /// Create a matcher for any IP address.
403    pub fn any() -> Self {
404        Self::Any
405    }
406
407    /// Create a matcher for a single IP address.
408    ///
409    /// # Example
410    /// ```
411    /// use axum_acl::IpMatcher;
412    /// use std::net::IpAddr;
413    ///
414    /// let matcher = IpMatcher::single("192.168.1.1".parse().unwrap());
415    /// ```
416    pub fn single(ip: IpAddr) -> Self {
417        Self::Single(ip)
418    }
419
420    /// Create a matcher for a CIDR network.
421    ///
422    /// # Example
423    /// ```
424    /// use axum_acl::IpMatcher;
425    ///
426    /// let matcher = IpMatcher::cidr("192.168.1.0/24".parse().unwrap());
427    /// ```
428    pub fn cidr(network: IpNetwork) -> Self {
429        Self::Network(network)
430    }
431
432    /// Parse an IP matcher from a string.
433    ///
434    /// Accepts:
435    /// - `*` or `any` for any IP
436    /// - A single IP address (e.g., `192.168.1.1`)
437    /// - A CIDR network (e.g., `192.168.1.0/24`)
438    ///
439    /// # Example
440    /// ```
441    /// use axum_acl::IpMatcher;
442    ///
443    /// let any = IpMatcher::parse("*").unwrap();
444    /// let single = IpMatcher::parse("10.0.0.1").unwrap();
445    /// let network = IpMatcher::parse("10.0.0.0/8").unwrap();
446    /// ```
447    pub fn parse(s: &str) -> Result<Self, String> {
448        let s = s.trim();
449        if s == "*" || s.eq_ignore_ascii_case("any") {
450            return Ok(Self::Any);
451        }
452
453        // Try as CIDR first
454        if s.contains('/') {
455            return s
456                .parse::<IpNetwork>()
457                .map(Self::Network)
458                .map_err(|e| format!("Invalid CIDR: {}", e));
459        }
460
461        // Try as single IP
462        s.parse::<IpAddr>()
463            .map(Self::Single)
464            .map_err(|e| format!("Invalid IP address: {}", e))
465    }
466
467    /// Check if an IP address matches this matcher.
468    pub fn matches(&self, ip: &IpAddr) -> bool {
469        match self {
470            Self::Any => true,
471            Self::Single(addr) => addr == ip,
472            Self::Network(network) => network.contains(*ip),
473            Self::List(matchers) => matchers.iter().any(|m| m.matches(ip)),
474        }
475    }
476}
477
478/// Endpoint pattern for rule matching.
479///
480/// Supports path parameters like `{id}` that match against the user's ID:
481/// - `/api/boat/{id}` matches `/api/boat/boat-123` if user ID is "boat-123"
482/// - `/api/user/{id}/**` matches any path under `/api/user/{user_id}/`
483#[derive(Debug, Clone, Default)]
484pub enum EndpointPattern {
485    /// Match any endpoint.
486    #[default]
487    Any,
488    /// Match an exact path.
489    Exact(String),
490    /// Match a path prefix (e.g., `/api/` matches `/api/users`).
491    Prefix(String),
492    /// Match using a glob pattern (e.g., `/api/*/users`).
493    /// Also supports `{id}` to match against user ID.
494    Glob(String),
495}
496
497impl EndpointPattern {
498    /// Create a pattern that matches any endpoint.
499    pub fn any() -> Self {
500        Self::Any
501    }
502
503    /// Create a pattern for an exact path match.
504    pub fn exact(path: impl Into<String>) -> Self {
505        Self::Exact(path.into())
506    }
507
508    /// Create a pattern for a prefix match.
509    pub fn prefix(path: impl Into<String>) -> Self {
510        Self::Prefix(path.into())
511    }
512
513    /// Create a glob pattern.
514    ///
515    /// Supported wildcards:
516    /// - `*` matches any single path segment
517    /// - `**` matches any number of path segments
518    pub fn glob(pattern: impl Into<String>) -> Self {
519        Self::Glob(pattern.into())
520    }
521
522    /// Parse an endpoint pattern from a string.
523    ///
524    /// - `*` or `any` - matches any endpoint
525    /// - Paths ending with `*` or `**` - glob pattern
526    /// - Paths ending with `/` - prefix match
527    /// - Other paths - exact match
528    pub fn parse(s: &str) -> Self {
529        let s = s.trim();
530        if s == "*" || s.eq_ignore_ascii_case("any") {
531            return Self::Any;
532        }
533
534        if s.contains('*') {
535            return Self::Glob(s.to_string());
536        }
537
538        if s.ends_with('/') {
539            return Self::Prefix(s.to_string());
540        }
541
542        Self::Exact(s.to_string())
543    }
544
545    /// Check if a path matches this pattern (without ID context).
546    pub fn matches(&self, path: &str) -> bool {
547        self.matches_with_id(path, None)
548    }
549
550    /// Check if a path matches this pattern with optional ID context.
551    ///
552    /// When `user_id` is provided, `{id}` in the pattern is matched against it.
553    /// When `user_id` is None, `{id}` is treated like `*` (matches any segment).
554    ///
555    /// # Example
556    /// ```
557    /// use axum_acl::EndpointPattern;
558    ///
559    /// let pattern = EndpointPattern::glob("/api/boat/{id}/details");
560    ///
561    /// // With matching user ID
562    /// assert!(pattern.matches_with_id("/api/boat/boat-123/details", Some("boat-123")));
563    ///
564    /// // With non-matching user ID
565    /// assert!(!pattern.matches_with_id("/api/boat/boat-456/details", Some("boat-123")));
566    ///
567    /// // Without ID context, {id} matches any segment
568    /// assert!(pattern.matches_with_id("/api/boat/anything/details", None));
569    /// ```
570    pub fn matches_with_id(&self, path: &str, user_id: Option<&str>) -> bool {
571        match self {
572            Self::Any => true,
573            Self::Exact(p) => p == path,
574            Self::Prefix(prefix) => path.starts_with(prefix),
575            Self::Glob(pattern) => Self::glob_matches_with_id(pattern, path, user_id),
576        }
577    }
578
579    fn glob_matches_with_id(pattern: &str, path: &str, user_id: Option<&str>) -> bool {
580        let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
581        let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
582
583        Self::glob_match_parts_with_id(&pattern_parts, &path_parts, user_id)
584    }
585
586    fn glob_match_parts_with_id(pattern: &[&str], path: &[&str], user_id: Option<&str>) -> bool {
587        if pattern.is_empty() {
588            return path.is_empty();
589        }
590
591        let (first_pattern, rest_pattern) = (pattern[0], &pattern[1..]);
592
593        if first_pattern == "**" {
594            // ** matches zero or more segments
595            if rest_pattern.is_empty() {
596                return true;
597            }
598            // Try matching ** against 0, 1, 2, ... path segments
599            for i in 0..=path.len() {
600                if Self::glob_match_parts_with_id(rest_pattern, &path[i..], user_id) {
601                    return true;
602                }
603            }
604            false
605        } else if path.is_empty() {
606            false
607        } else {
608            let (first_path, rest_path) = (path[0], &path[1..]);
609
610            // Check if this is an {id} parameter
611            let segment_matches = if first_pattern == "{id}" {
612                // Match against user ID if provided, otherwise treat as wildcard
613                match user_id {
614                    Some(id) => first_path == id,
615                    None => true, // No ID context, treat as wildcard
616                }
617            } else if first_pattern.starts_with('{') && first_pattern.ends_with('}') {
618                // Other path parameters like {user_id}, {boat_id} - treat as wildcards
619                true
620            } else {
621                first_pattern == "*" || first_pattern == first_path
622            };
623
624            segment_matches && Self::glob_match_parts_with_id(rest_pattern, rest_path, user_id)
625        }
626    }
627
628    /// Extract the value of `{id}` from a path given this pattern.
629    ///
630    /// Returns None if the pattern doesn't contain `{id}` or the path doesn't match.
631    ///
632    /// # Example
633    /// ```
634    /// use axum_acl::EndpointPattern;
635    ///
636    /// let pattern = EndpointPattern::glob("/api/boat/{id}/details");
637    /// assert_eq!(pattern.extract_id("/api/boat/boat-123/details"), Some("boat-123".to_string()));
638    /// assert_eq!(pattern.extract_id("/api/boat/xyz/details"), Some("xyz".to_string()));
639    /// assert_eq!(pattern.extract_id("/api/wrong/path"), None);
640    /// ```
641    pub fn extract_id(&self, path: &str) -> Option<String> {
642        match self {
643            Self::Glob(pattern) => Self::extract_id_from_glob(pattern, path),
644            _ => None,
645        }
646    }
647
648    fn extract_id_from_glob(pattern: &str, path: &str) -> Option<String> {
649        let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
650        let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
651
652        Self::extract_id_from_parts(&pattern_parts, &path_parts)
653    }
654
655    /// Extract all named path parameters from a path given this pattern.
656    ///
657    /// Returns a map of parameter names to their values.
658    ///
659    /// # Example
660    /// ```
661    /// use axum_acl::EndpointPattern;
662    ///
663    /// let pattern = EndpointPattern::glob("/api/{resource}/{id}");
664    /// let params = pattern.extract_named_params("/api/boat/123");
665    /// assert_eq!(params.get("resource").map(|s| s.as_str()), Some("boat"));
666    /// assert_eq!(params.get("id").map(|s| s.as_str()), Some("123"));
667    /// ```
668    pub fn extract_named_params(&self, path: &str) -> HashMap<String, String> {
669        match self {
670            Self::Glob(pattern) => {
671                let pattern_parts: Vec<&str> =
672                    pattern.split('/').filter(|s| !s.is_empty()).collect();
673                let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
674                let mut params = HashMap::new();
675                Self::collect_named_params(&pattern_parts, &path_parts, &mut params);
676                params
677            }
678            _ => HashMap::new(),
679        }
680    }
681
682    fn collect_named_params<'a>(
683        pattern: &[&str],
684        path: &[&'a str],
685        params: &mut HashMap<String, String>,
686    ) {
687        let mut pi = 0;
688        let mut qi = 0;
689        while pi < pattern.len() && qi < path.len() {
690            let seg = pattern[pi];
691            if seg == "**" {
692                if pi + 1 >= pattern.len() {
693                    return;
694                }
695                // skip ahead in path until remaining pattern matches
696                for skip in qi..=path.len() {
697                    let mut trial = HashMap::new();
698                    Self::collect_named_params(&pattern[pi + 1..], &path[skip..], &mut trial);
699                    if !trial.is_empty() || (pi + 1 == pattern.len() - 1 && skip < path.len()) {
700                        params.extend(trial);
701                        return;
702                    }
703                }
704                return;
705            }
706            if seg.starts_with('{') && seg.ends_with('}') {
707                let name = &seg[1..seg.len() - 1];
708                params.insert(name.to_string(), path[qi].to_string());
709            }
710            pi += 1;
711            qi += 1;
712        }
713    }
714
715    fn extract_id_from_parts(pattern: &[&str], path: &[&str]) -> Option<String> {
716        if pattern.is_empty() || path.is_empty() {
717            return None;
718        }
719
720        for (i, &p) in pattern.iter().enumerate() {
721            if p == "{id}" {
722                if i < path.len() {
723                    return Some(path[i].to_string());
724                }
725                return None;
726            }
727            if p == "**" {
728                // Can't reliably extract after **
729                continue;
730            }
731            if i >= path.len() {
732                return None;
733            }
734            // Check if pattern segment matches (for non-{id} segments)
735            if p != "*" && p != path[i] && !p.starts_with('{') {
736                return None;
737            }
738        }
739        None
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    #[test]
748    fn test_ip_matcher_single() {
749        let ip: IpAddr = "192.168.1.1".parse().unwrap();
750        let matcher = IpMatcher::single(ip);
751        assert!(matcher.matches(&ip));
752        assert!(!matcher.matches(&"192.168.1.2".parse().unwrap()));
753    }
754
755    #[test]
756    fn test_ip_matcher_cidr() {
757        let matcher = IpMatcher::cidr("192.168.1.0/24".parse().unwrap());
758        assert!(matcher.matches(&"192.168.1.1".parse().unwrap()));
759        assert!(matcher.matches(&"192.168.1.255".parse().unwrap()));
760        assert!(!matcher.matches(&"192.168.2.1".parse().unwrap()));
761    }
762
763    #[test]
764    fn test_endpoint_exact() {
765        let pattern = EndpointPattern::exact("/api/users");
766        assert!(pattern.matches("/api/users"));
767        assert!(!pattern.matches("/api/users/"));
768        assert!(!pattern.matches("/api/users/1"));
769    }
770
771    #[test]
772    fn test_endpoint_prefix() {
773        let pattern = EndpointPattern::prefix("/api/");
774        assert!(pattern.matches("/api/users"));
775        assert!(pattern.matches("/api/users/1"));
776        assert!(!pattern.matches("/admin/users"));
777    }
778
779    #[test]
780    fn test_endpoint_glob() {
781        let pattern = EndpointPattern::glob("/api/*/users");
782        assert!(pattern.matches("/api/v1/users"));
783        assert!(pattern.matches("/api/v2/users"));
784        assert!(!pattern.matches("/api/v1/posts"));
785
786        let pattern = EndpointPattern::glob("/api/**");
787        assert!(pattern.matches("/api/users"));
788        assert!(pattern.matches("/api/v1/users/1"));
789    }
790
791    #[test]
792    fn test_endpoint_glob_with_id() {
793        let pattern = EndpointPattern::glob("/api/boat/{id}/details");
794
795        // Without ID context, {id} matches any segment
796        assert!(pattern.matches("/api/boat/boat-123/details"));
797        assert!(pattern.matches("/api/boat/anything/details"));
798
799        // With matching user ID
800        assert!(pattern.matches_with_id("/api/boat/boat-123/details", Some("boat-123")));
801
802        // With non-matching user ID
803        assert!(!pattern.matches_with_id("/api/boat/boat-456/details", Some("boat-123")));
804
805        // More complex pattern
806        let pattern = EndpointPattern::glob("/api/user/{id}/**");
807        assert!(pattern.matches_with_id("/api/user/user-1/profile", Some("user-1")));
808        assert!(pattern.matches_with_id("/api/user/user-1/boats/123", Some("user-1")));
809        assert!(!pattern.matches_with_id("/api/user/user-2/profile", Some("user-1")));
810    }
811
812    #[test]
813    fn test_extract_id_from_path() {
814        let pattern = EndpointPattern::glob("/api/boat/{id}/details");
815        assert_eq!(pattern.extract_id("/api/boat/boat-123/details"), Some("boat-123".to_string()));
816        assert_eq!(pattern.extract_id("/api/boat/xyz/details"), Some("xyz".to_string()));
817        assert_eq!(pattern.extract_id("/api/wrong/path"), None);
818
819        let pattern = EndpointPattern::glob("/users/{id}");
820        assert_eq!(pattern.extract_id("/users/123"), Some("123".to_string()));
821        assert_eq!(pattern.extract_id("/users/"), None);
822    }
823
824    #[test]
825    fn test_filter_matches() {
826        let filter = AclRuleFilter::new()
827            .role_mask(0b001)  // admin role
828            .ip(IpMatcher::any());
829
830        let ip: IpAddr = "10.0.0.1".parse().unwrap();
831
832        // Admin matches
833        let ctx = RequestContext::new(0b001, ip, "*");
834        assert!(filter.matches(&ctx));
835
836        // User (0b010) doesn't match admin filter (0b001)
837        let ctx = RequestContext::new(0b010, ip, "*");
838        assert!(!filter.matches(&ctx));
839
840        // Admin + User (0b011) matches because admin bit is set
841        let ctx = RequestContext::new(0b011, ip, "*");
842        assert!(filter.matches(&ctx));
843    }
844
845    #[test]
846    fn test_filter_id_match() {
847        let filter = AclRuleFilter::new()
848            .id("user123")
849            .role_mask(u32::MAX);
850
851        let ip: IpAddr = "10.0.0.1".parse().unwrap();
852
853        // Exact ID match
854        let ctx = RequestContext::new(0b1, ip, "user123");
855        assert!(filter.matches(&ctx));
856
857        // Different ID doesn't match
858        let ctx = RequestContext::new(0b1, ip, "user456");
859        assert!(!filter.matches(&ctx));
860    }
861
862    #[test]
863    fn test_filter_wildcard_id() {
864        let filter = AclRuleFilter::new()
865            .id("*")
866            .role_mask(u32::MAX);
867
868        let ip: IpAddr = "10.0.0.1".parse().unwrap();
869
870        // Wildcard matches any ID
871        let ctx = RequestContext::new(0b1, ip, "anyone");
872        assert!(filter.matches(&ctx));
873    }
874
875    #[test]
876    fn test_extract_named_params() {
877        let pattern = EndpointPattern::glob("/api/{resource}/{id}/details");
878        let params = pattern.extract_named_params("/api/boat/123/details");
879        assert_eq!(params.get("resource").map(|s| s.as_str()), Some("boat"));
880        assert_eq!(params.get("id").map(|s| s.as_str()), Some("123"));
881
882        let pattern = EndpointPattern::glob("/api/groups/{group_id}/factions/{faction_id}");
883        let params = pattern.extract_named_params("/api/groups/abc-123/factions/def-456");
884        assert_eq!(params.get("group_id").map(|s| s.as_str()), Some("abc-123"));
885        assert_eq!(params.get("faction_id").map(|s| s.as_str()), Some("def-456"));
886
887        // Non-glob returns empty
888        let pattern = EndpointPattern::exact("/api/users");
889        let params = pattern.extract_named_params("/api/users");
890        assert!(params.is_empty());
891    }
892
893    #[test]
894    fn test_rule_matcher_bitmask_auth() {
895        let filter = AclRuleFilter::new()
896            .role_mask(0b001)
897            .action(AclAction::Allow);
898
899        let ip: IpAddr = "10.0.0.1".parse().unwrap();
900        let meta = RequestMeta {
901            method: Method::GET,
902            path: "/api/users".to_string(),
903            path_params: HashMap::new(),
904            ip,
905        };
906
907        let auth = BitmaskAuth { roles: 0b001, id: "*".to_string() };
908        assert!(RuleMatcher::matches(&filter, &auth, &meta));
909
910        let auth = BitmaskAuth { roles: 0b010, id: "*".to_string() };
911        assert!(!RuleMatcher::matches(&filter, &auth, &meta));
912    }
913
914    #[test]
915    fn test_rule_matcher_method_filtering() {
916        let filter = AclRuleFilter::new()
917            .role_mask(u32::MAX)
918            .method(Method::POST)
919            .action(AclAction::Allow);
920
921        let ip: IpAddr = "10.0.0.1".parse().unwrap();
922        let auth = BitmaskAuth { roles: 0b1, id: "*".to_string() };
923
924        let meta_post = RequestMeta {
925            method: Method::POST,
926            path: "/api/users".to_string(),
927            path_params: HashMap::new(),
928            ip,
929        };
930        assert!(RuleMatcher::matches(&filter, &auth, &meta_post));
931
932        let meta_get = RequestMeta {
933            method: Method::GET,
934            path: "/api/users".to_string(),
935            path_params: HashMap::new(),
936            ip,
937        };
938        assert!(!RuleMatcher::matches(&filter, &auth, &meta_get));
939
940        // Empty methods = all methods
941        let filter_any = AclRuleFilter::new()
942            .role_mask(u32::MAX)
943            .action(AclAction::Allow);
944        assert!(RuleMatcher::matches(&filter_any, &auth, &meta_get));
945        assert!(RuleMatcher::matches(&filter_any, &auth, &meta_post));
946    }
947}