1use chrono::{Datelike, NaiveTime, Utc};
13use http::Method;
14use ipnetwork::IpNetwork;
15use std::collections::HashMap;
16use std::net::IpAddr;
17
18#[derive(Debug, Clone)]
20pub struct RequestContext<'a> {
21 pub roles: u32,
23 pub ip: IpAddr,
25 pub id: &'a str,
27}
28
29impl<'a> RequestContext<'a> {
30 pub fn new(roles: u32, ip: IpAddr, id: &'a str) -> Self {
32 Self { roles, ip, id }
33 }
34}
35
36#[derive(Debug, Clone)]
41pub struct BitmaskAuth {
42 pub roles: u32,
44 pub id: String,
46}
47
48#[derive(Debug, Clone)]
50pub struct RequestMeta {
51 pub method: Method,
53 pub path: String,
55 pub path_params: HashMap<String, String>,
57 pub ip: IpAddr,
59}
60
61pub trait RuleMatcher<A>: Send + Sync + std::fmt::Debug {
66 fn matches(&self, auth: &A, meta: &RequestMeta) -> bool;
68
69 fn action(&self) -> &AclAction;
71
72 fn description(&self) -> Option<&str> {
74 None
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
80pub enum AclAction {
81 #[default]
83 Allow,
84 Deny,
86 Error {
88 code: u16,
90 message: Option<String>,
92 },
93 Reroute {
95 target: String,
97 preserve_path: bool,
99 },
100 RateLimit {
102 max_requests: u32,
104 window_secs: u64,
106 },
107 Log {
109 level: String,
111 message: Option<String>,
113 },
114}
115
116impl AclAction {
117 pub fn deny() -> Self {
119 Self::Deny
120 }
121
122 pub fn allow() -> Self {
124 Self::Allow
125 }
126
127 pub fn error(code: u16, message: impl Into<Option<String>>) -> Self {
129 Self::Error {
130 code,
131 message: message.into(),
132 }
133 }
134
135 pub fn reroute(target: impl Into<String>) -> Self {
137 Self::Reroute {
138 target: target.into(),
139 preserve_path: false,
140 }
141 }
142
143 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 pub fn is_allow(&self) -> bool {
153 matches!(self, Self::Allow | Self::Log { .. })
154 }
155
156 pub fn is_deny(&self) -> bool {
158 matches!(self, Self::Deny | Self::Error { .. })
159 }
160}
161
162#[derive(Debug, Clone)]
167pub struct AclRuleFilter {
168 pub id: String,
170 pub role_mask: u32,
172 pub methods: Vec<Method>,
174 pub time: TimeWindow,
176 pub ip: IpMatcher,
178 pub action: AclAction,
180 pub description: Option<String>,
182}
183
184impl AclRuleFilter {
185 pub fn new() -> Self {
187 Self {
188 id: "*".to_string(),
189 role_mask: u32::MAX, methods: Vec::new(),
191 time: TimeWindow::default(),
192 ip: IpMatcher::Any,
193 action: AclAction::Allow,
194 description: None,
195 }
196 }
197
198 pub fn id(mut self, id: impl Into<String>) -> Self {
200 self.id = id.into();
201 self
202 }
203
204 pub fn role_mask(mut self, mask: u32) -> Self {
206 self.role_mask = mask;
207 self
208 }
209
210 pub fn role(mut self, role_id: u8) -> Self {
212 self.role_mask = 1 << role_id;
213 self
214 }
215
216 pub fn add_role(mut self, role_id: u8) -> Self {
218 self.role_mask |= 1 << role_id;
219 self
220 }
221
222 pub fn methods(mut self, methods: Vec<Method>) -> Self {
224 self.methods = methods;
225 self
226 }
227
228 pub fn method(mut self, method: Method) -> Self {
230 self.methods.push(method);
231 self
232 }
233
234 pub fn time(mut self, window: TimeWindow) -> Self {
236 self.time = window;
237 self
238 }
239
240 pub fn ip(mut self, matcher: IpMatcher) -> Self {
242 self.ip = matcher;
243 self
244 }
245
246 pub fn action(mut self, action: AclAction) -> Self {
248 self.action = action;
249 self
250 }
251
252 pub fn description(mut self, desc: impl Into<String>) -> Self {
254 self.description = Some(desc.into());
255 self
256 }
257
258 #[inline]
262 pub fn matches(&self, ctx: &RequestContext) -> bool {
263 (self.id == "*" || self.id == ctx.id)
265 && (self.role_mask & ctx.roles) != 0
267 && self.ip.matches(&ctx.ip)
269 && 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#[derive(Debug, Clone, Default)]
303pub struct TimeWindow {
304 pub start: Option<NaiveTime>,
306 pub end: Option<NaiveTime>,
308 pub days: Vec<u32>,
311}
312
313impl TimeWindow {
314 pub fn any() -> Self {
316 Self::default()
317 }
318
319 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 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 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 if !self.days.is_empty() && !self.days.contains(¤t_day) {
366 return false;
367 }
368
369 match (&self.start, &self.end) {
371 (Some(start), Some(end)) => {
372 if start <= end {
373 current_time >= *start && current_time <= *end
375 } else {
376 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#[derive(Debug, Clone, Default)]
389pub enum IpMatcher {
390 #[default]
392 Any,
393 Single(IpAddr),
395 Network(IpNetwork),
397 List(Vec<IpMatcher>),
399}
400
401impl IpMatcher {
402 pub fn any() -> Self {
404 Self::Any
405 }
406
407 pub fn single(ip: IpAddr) -> Self {
417 Self::Single(ip)
418 }
419
420 pub fn cidr(network: IpNetwork) -> Self {
429 Self::Network(network)
430 }
431
432 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 if s.contains('/') {
455 return s
456 .parse::<IpNetwork>()
457 .map(Self::Network)
458 .map_err(|e| format!("Invalid CIDR: {}", e));
459 }
460
461 s.parse::<IpAddr>()
463 .map(Self::Single)
464 .map_err(|e| format!("Invalid IP address: {}", e))
465 }
466
467 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#[derive(Debug, Clone, Default)]
484pub enum EndpointPattern {
485 #[default]
487 Any,
488 Exact(String),
490 Prefix(String),
492 Glob(String),
495}
496
497impl EndpointPattern {
498 pub fn any() -> Self {
500 Self::Any
501 }
502
503 pub fn exact(path: impl Into<String>) -> Self {
505 Self::Exact(path.into())
506 }
507
508 pub fn prefix(path: impl Into<String>) -> Self {
510 Self::Prefix(path.into())
511 }
512
513 pub fn glob(pattern: impl Into<String>) -> Self {
519 Self::Glob(pattern.into())
520 }
521
522 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 pub fn matches(&self, path: &str) -> bool {
547 self.matches_with_id(path, None)
548 }
549
550 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 if rest_pattern.is_empty() {
596 return true;
597 }
598 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 let segment_matches = if first_pattern == "{id}" {
612 match user_id {
614 Some(id) => first_path == id,
615 None => true, }
617 } else if first_pattern.starts_with('{') && first_pattern.ends_with('}') {
618 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 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 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 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 continue;
730 }
731 if i >= path.len() {
732 return None;
733 }
734 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 assert!(pattern.matches("/api/boat/boat-123/details"));
797 assert!(pattern.matches("/api/boat/anything/details"));
798
799 assert!(pattern.matches_with_id("/api/boat/boat-123/details", Some("boat-123")));
801
802 assert!(!pattern.matches_with_id("/api/boat/boat-456/details", Some("boat-123")));
804
805 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) .ip(IpMatcher::any());
829
830 let ip: IpAddr = "10.0.0.1".parse().unwrap();
831
832 let ctx = RequestContext::new(0b001, ip, "*");
834 assert!(filter.matches(&ctx));
835
836 let ctx = RequestContext::new(0b010, ip, "*");
838 assert!(!filter.matches(&ctx));
839
840 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 let ctx = RequestContext::new(0b1, ip, "user123");
855 assert!(filter.matches(&ctx));
856
857 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 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 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 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}