1use crate::acct::{log_authz, AuditLogParams};
2use crate::http_server::auth::HttpEndpointResource;
3use async_trait::async_trait;
4use axum::extract::FromRequestParts;
5use axum::http::{StatusCode, Uri};
6use chrono::{DateTime, Utc};
7use cidr_map::CidrSet;
8use config::{
9 any_err, get_or_create_sub_module, load_config, serialize_options, SerdeWrappedValue,
10};
11use data_loader::KeySource;
12use mlua::prelude::LuaUserData;
13use mlua::{Lua, LuaSerdeExt, UserDataFields, UserDataMethods, UserDataRef};
14use mod_memoize::Memoized;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::net::{IpAddr, Ipv4Addr};
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19use std::sync::{Arc, LazyLock};
20use std::time::Duration;
21
22static ACL_MAP: LazyLock<AccessControlListMap> =
23 LazyLock::new(|| AccessControlListMap::compiled_default());
24static FALL_BACK_TO_ACL_MAP: AtomicBool = AtomicBool::new(true);
25
26lruttl::declare_cache! {
27static ACL_CACHE: LruCacheWithTtl<String, AccessControlListCacheEntry>::new("acl_definition", 128);
28}
29static ACL_CACHE_TTL_MS: AtomicUsize = AtomicUsize::new(300);
30
31lruttl::declare_cache! {
32static CHECK_CACHE: LruCacheWithTtl<CheckKey, AuditRecord>::new("acl_check", 128);
33}
34static CHECK_CACHE_TTL_MS: AtomicUsize = AtomicUsize::new(300);
35
36config::declare_event! {
37static GET_ACL_DEF_SIG: Multiple(
38 "get_acl_definition",
39 resource: &'static str
40) -> Option<UserDataRef<AccessControlListWrap>>;
41}
42
43#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
90pub struct Identity {
91 pub identity: String,
92 pub context: IdentityContext,
93}
94
95#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize, Deserialize)]
96pub enum IdentityContext {
97 SmtpAuthPlainAuthentication,
98 SmtpAuthPlainAuthorization,
99 HttpBasicAuth,
100 BearerToken,
101 ProxyAuthRfc1929,
102 LocalSystem,
103 GenericAuth,
104}
105
106#[derive(Default, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
107pub struct AuthInfo {
108 #[serde(default)]
110 pub peer_address: Option<IpAddr>,
111 #[serde(default)]
113 pub identities: Vec<Identity>,
114 #[serde(default)]
116 pub groups: Vec<String>,
117}
118
119impl std::fmt::Display for AuthInfo {
120 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
121 write!(fmt, "{}", self.public_descriptor())
122 }
123}
124
125impl AuthInfo {
126 pub fn new_local_system() -> Self {
129 Self {
130 peer_address: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
131 identities: vec![Identity {
132 identity: "kumomta.internal".to_string(),
133 context: IdentityContext::LocalSystem,
134 }],
135 groups: vec![],
136 }
137 }
138
139 pub fn summarize_for_http_auth(&self) -> String {
140 if let Some(ident) = self
141 .identities
142 .iter()
143 .find(|ident| ident.context == IdentityContext::HttpBasicAuth)
144 {
145 return ident.identity.to_string();
146 }
147 if let Some(ident) = self.identities.first() {
148 return ident.identity.to_string();
149 }
150 match &self.peer_address {
151 Some(addr) => addr.to_string(),
152 None => "".to_string(),
153 }
154 }
155
156 pub fn public_descriptor(&self) -> String {
160 let mut result = String::new();
161
162 if self.identities.is_empty() {
163 result.push_str("<Unauthenticated>");
164 } else {
165 result.push_str("id=[");
166 for (idx, id) in self.identities.iter().enumerate() {
167 if idx > 0 {
168 result.push_str(", ");
169 }
170 result.push_str(&id.identity);
171 }
172 result.push(']');
173 }
174 if let Some(peer) = &self.peer_address {
175 result.push_str(&format!("@ ip={peer}"));
176 }
177 result
178 }
179
180 pub fn merge_from(&mut self, mut other: Self) {
182 self.identities.append(&mut other.identities);
183 self.groups.append(&mut other.groups);
184 }
185
186 pub fn add_identity(&mut self, identity: Identity) {
188 self.identities.push(identity);
189 }
190
191 pub fn add_group(&mut self, group_name: impl Into<String>) {
192 self.groups.push(group_name.into());
193 }
194
195 pub fn set_peer_address(&mut self, peer_address: Option<IpAddr>) {
197 self.peer_address = peer_address;
198 }
199
200 pub fn matches_identity(&self, identity: &ACLIdentity) -> bool {
202 match identity {
203 ACLIdentity::Any => true,
204 ACLIdentity::Unauthenticated => self.identities.is_empty(),
205 ACLIdentity::Authenticated => !self.identities.is_empty(),
206 ACLIdentity::Individual(candidate_ident) => {
207 for ident in &self.identities {
208 if ident.identity == *candidate_ident {
209 return true;
210 }
211 }
212 false
213 }
214 ACLIdentity::Group(candidate_group) => self.groups.iter().any(|g| g == candidate_group),
215 ACLIdentity::Machine(candidate_ip) => self
216 .peer_address
217 .as_ref()
218 .map(|ip| ip == candidate_ip)
219 .unwrap_or(false),
220 ACLIdentity::MachineSet(cidr_set) => self
221 .peer_address
222 .as_ref()
223 .map(|ip| cidr_set.contains(*ip))
224 .unwrap_or(false),
225 }
226 }
227
228 pub fn matches_criteria(&self, criteria: &ACLRuleTerm) -> bool {
229 match criteria {
230 ACLRuleTerm::Not(term) => !self.matches_criteria(term),
231 ACLRuleTerm::AnyOf(terms) => terms.iter().any(|term| self.matches_criteria(term)),
232 ACLRuleTerm::AllOf(terms) => terms.iter().all(|term| self.matches_criteria(term)),
233 ACLRuleTerm::Identity(ident) => self.matches_identity(ident),
234 }
235 }
236}
237
238impl<B> FromRequestParts<B> for AuthInfo
239where
240 B: Send + Sync,
241{
242 type Rejection = (StatusCode, &'static str);
243
244 async fn from_request_parts(
245 parts: &mut axum::http::request::Parts,
246 _: &B,
247 ) -> Result<Self, Self::Rejection> {
248 match parts.extensions.get::<AuthInfo>() {
249 Some(info) => Ok(info.clone()),
250 None => Ok(AuthInfo::default()),
251 }
252 }
253}
254
255#[derive(Clone, Debug)]
256enum AccessControlListCacheEntry {
257 None,
258 Hit(Arc<AccessControlList>),
259 Err(String),
260}
261
262#[derive(Hash, PartialEq, Eq, Debug, Clone)]
263struct CheckKey {
264 pub target_resource: String,
265 pub privilege: String,
266 pub auth_info: AuthInfo,
267}
268
269#[derive(Debug, Serialize, Deserialize, Clone)]
270pub struct AuditRecord {
271 pub timestamp: DateTime<Utc>,
273 pub target_resource: String,
275 pub privilege: String,
277 pub access: Access,
279 pub matching_resource: Option<String>,
282 pub rule: Option<AccessControlRule>,
284 pub auth_info: AuthInfo,
286 pub considered_resources: Vec<String>,
291}
292
293impl AuditRecord {
294 pub fn new(
295 disposition: &ACLQueryDisposition,
296 target_resource: &str,
297 privilege: &str,
298 info: &AuthInfo,
299 considered_resources: Vec<String>,
300 ) -> Self {
301 let (access, matching_resource, rule) = match disposition {
302 ACLQueryDisposition::Allow { rule, resource } => (
303 Access::Allow,
304 Some(resource.to_string()),
305 Some(rule.clone()),
306 ),
307 ACLQueryDisposition::Deny { rule, resource } => {
308 (Access::Deny, Some(resource.to_string()), Some(rule.clone()))
309 }
310 ACLQueryDisposition::DenyByDefault => (Access::Deny, None, None),
311 };
312
313 Self {
314 target_resource: target_resource.to_string(),
315 privilege: privilege.to_string(),
316 access,
317 matching_resource,
318 rule,
319 auth_info: info.clone(),
320 considered_resources,
321 timestamp: Utc::now(),
322 }
323 }
324
325 pub fn disposition(&self) -> ACLQueryDisposition {
326 match (&self.matching_resource, &self.rule, self.access) {
327 (None, None, Access::Deny) => ACLQueryDisposition::DenyByDefault,
328 (Some(resource), Some(rule), Access::Allow) => ACLQueryDisposition::Allow {
329 rule: rule.clone(),
330 resource: resource.clone(),
331 },
332 (Some(resource), Some(rule), Access::Deny) => ACLQueryDisposition::Deny {
333 rule: rule.clone(),
334 resource: resource.clone(),
335 },
336 _ => ACLQueryDisposition::DenyByDefault,
337 }
338 }
339}
340
341#[derive(Clone)]
342struct AccessControlListWrap {
343 wrapped_acl: Arc<AccessControlList>,
344}
345impl LuaUserData for AccessControlListWrap {}
346
347#[derive(Clone, Debug, Serialize, Deserialize)]
349pub struct AccessControlList {
350 pub rules: Vec<AccessControlRule>,
355}
356
357impl AccessControlList {
358 pub async fn query_resource_access(
359 resource: &mut impl Resource,
360 info: &AuthInfo,
361 privilege: &str,
362 ) -> anyhow::Result<ACLQueryDisposition> {
363 let key = CheckKey {
364 auth_info: info.clone(),
365 privilege: privilege.to_string(),
366 target_resource: resource.resource_id().to_string(),
367 };
368
369 let lookup = CHECK_CACHE
370 .get_or_try_insert(&key, |_| get_check_cache_ttl(), async move {
371 let mut considered_resources = vec![];
372
373 while let Some(resource_id) = resource.next_resource_id().await {
374 tracing::trace!("ACL: consider {resource_id} priv={privilege} {info:?}");
375 if let Some(acl) = Self::load_for_resource(&resource_id).await? {
376 let result = acl.query_access(resource_id.clone(), info, privilege);
377 tracing::trace!(
378 "ACL: check {resource_id} priv={privilege} {info:?} -> {result:?}"
379 );
380 if result != ACLQueryDisposition::DenyByDefault {
381 let record = AuditRecord::new(
382 &result,
383 resource.resource_id(),
384 privilege,
385 info,
386 considered_resources,
387 );
388
389 return Ok::<_, anyhow::Error>(record);
390 }
391 }
392
393 considered_resources.push(resource_id);
394 }
395
396 Ok(AuditRecord::new(
397 &ACLQueryDisposition::DenyByDefault,
398 resource.resource_id(),
399 privilege,
400 info,
401 considered_resources,
402 ))
403 })
404 .await
405 .map_err(|err| anyhow::anyhow!("{err:#}"))?;
406
407 let mut audit_item = lookup.item.clone();
408 audit_item.timestamp = Utc::now();
409 log_authz(audit_item).await?;
410 Ok(lookup.item.disposition())
411 }
412
413 fn query_access(
414 &self,
415 resource: String,
416 info: &AuthInfo,
417 privilege: &str,
418 ) -> ACLQueryDisposition {
419 for rule in &self.rules {
420 if !info.matches_criteria(&rule.criteria) {
421 continue;
422 }
423 if rule.privilege != privilege {
424 continue;
425 }
426 return match &rule.access {
427 Access::Allow => ACLQueryDisposition::Allow {
428 rule: rule.clone(),
429 resource,
430 },
431 Access::Deny => ACLQueryDisposition::Deny {
432 rule: rule.clone(),
433 resource,
434 },
435 };
436 }
437
438 ACLQueryDisposition::DenyByDefault
439 }
440
441 async fn load_for_resource_impl(resource: &str) -> anyhow::Result<Option<Arc<Self>>> {
442 let mut config = load_config().await?;
443 let result = config.call_callback(&GET_ACL_DEF_SIG, resource).await?;
444 config.put();
445
446 if let Some(Some(result)) = result.result {
447 return Ok(Some(result.wrapped_acl.clone()));
448 }
449
450 if !result.handler_was_defined || FALL_BACK_TO_ACL_MAP.load(Ordering::Relaxed) {
451 Ok(ACL_MAP.get(resource))
452 } else {
453 Ok(None)
454 }
455 }
456
457 pub async fn load_for_resource(resource: &str) -> anyhow::Result<Option<Arc<Self>>> {
458 let resource = resource.to_string();
459 match ACL_CACHE
460 .get_or_try_insert(&resource, |_| get_acl_cache_ttl(), {
461 let resource = resource.clone();
462 async move {
463 match Self::load_for_resource_impl(&resource).await {
464 Err(err) => Ok::<_, anyhow::Error>(AccessControlListCacheEntry::Err(
465 format!("{err:#}"),
466 )),
467 Ok(Some(result)) => Ok(AccessControlListCacheEntry::Hit(result)),
468 Ok(None) => Ok(AccessControlListCacheEntry::None),
469 }
470 }
471 })
472 .await
473 {
474 Err(err) => Err(anyhow::anyhow!("{err:#}")),
475 Ok(lookup) => match lookup.item {
476 AccessControlListCacheEntry::Err(err) => Err(anyhow::anyhow!("{err}")),
477 AccessControlListCacheEntry::None => Ok(None),
478 AccessControlListCacheEntry::Hit(res) => Ok(Some(res)),
479 },
480 }
481 }
482}
483
484pub fn get_acl_cache_ttl() -> Duration {
485 Duration::from_millis(ACL_CACHE_TTL_MS.load(Ordering::Relaxed) as u64)
486}
487pub fn get_check_cache_ttl() -> Duration {
488 Duration::from_millis(CHECK_CACHE_TTL_MS.load(Ordering::Relaxed) as u64)
489}
490
491#[derive(PartialEq, Debug)]
492pub enum ACLQueryDisposition {
493 Allow {
495 resource: String,
496 rule: AccessControlRule,
497 },
498 Deny {
500 resource: String,
501 rule: AccessControlRule,
502 },
503 DenyByDefault,
508}
509
510impl LuaUserData for ACLQueryDisposition {
511 fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
512 fields.add_field_method_get("allow", move |_lua, this| {
513 Ok(matches!(this, Self::Allow { .. }))
514 });
515 fields.add_field_method_get("rule", move |lua, this| match this {
516 Self::Allow { rule, .. } | Self::Deny { rule, .. } => {
517 lua.to_value_with(rule, serialize_options())
518 }
519 Self::DenyByDefault => Ok(mlua::Value::Nil),
520 });
521 fields.add_field_method_get("resource", move |_lua, this| match this {
522 Self::Allow { resource, .. } | Self::Deny { resource, .. } => {
523 Ok(Some(resource.to_string()))
524 }
525 Self::DenyByDefault => Ok(None),
526 });
527 }
528}
529
530#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
531pub enum ACLIdentity {
532 Individual(String),
534 Group(String),
536 Machine(IpAddr),
538 MachineSet(CidrSet),
540 Authenticated,
542 Unauthenticated,
544 Any,
546}
547
548impl std::fmt::Display for ACLIdentity {
549 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
550 match self {
551 Self::Authenticated => write!(fmt, "Authenticated"),
552 Self::Unauthenticated => write!(fmt, "Unauthenticated"),
553 Self::Any => write!(fmt, "Any"),
554 Self::Individual(user) => write!(fmt, "{user}"),
555 Self::Group(group) => write!(fmt, "group={group}"),
556 Self::Machine(ip) => write!(fmt, "ip={ip}"),
557 Self::MachineSet(cidr) => write!(fmt, "ip={cidr:?}"),
558 }
559 }
560}
561
562#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)]
563pub enum Access {
564 Allow,
565 Deny,
566}
567
568#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
569pub enum ACLRuleTerm {
570 AllOf(Vec<ACLRuleTerm>),
571 AnyOf(Vec<ACLRuleTerm>),
572 Not(Box<ACLRuleTerm>),
573 Identity(ACLIdentity),
574}
575
576impl std::fmt::Display for ACLRuleTerm {
577 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
578 match self {
579 Self::AllOf(terms) => {
580 write!(fmt, "AllOf [")?;
581 for (idx, t) in terms.iter().enumerate() {
582 if idx > 0 {
583 write!(fmt, ", ")?;
584 }
585 t.fmt(fmt)?;
586 }
587 write!(fmt, "]")
588 }
589 Self::AnyOf(terms) => {
590 write!(fmt, "AnyOf [")?;
591 for (idx, t) in terms.iter().enumerate() {
592 if idx > 0 {
593 write!(fmt, ", ")?;
594 }
595 t.fmt(fmt)?;
596 }
597 write!(fmt, "]")
598 }
599 Self::Not(term) => {
600 write!(fmt, "!{term}")
601 }
602 Self::Identity(ident) => {
603 write!(fmt, "{ident}")
604 }
605 }
606 }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
610pub struct AccessControlRule {
611 pub criteria: ACLRuleTerm,
612 pub privilege: String,
613 pub access: Access,
614}
615
616impl std::fmt::Display for AccessControlRule {
617 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
618 write!(
619 fmt,
620 "{} {} {}",
621 self.criteria,
622 match self.access {
623 Access::Allow => "allows",
624 Access::Deny => "denies",
625 },
626 self.privilege
627 )
628 }
629}
630
631#[async_trait]
651pub trait Resource {
652 fn resource_id(&self) -> &str;
654 async fn next_resource_id(&mut self) -> Option<String>;
658}
659
660#[derive(Clone)]
661struct AccessControlListMapWrap {
662 wrapped_map: Arc<AccessControlListMap>,
663}
664
665impl LuaUserData for AccessControlListMapWrap {
666 fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
667 methods.add_method("get", move |_lua, this, resource: String| {
668 Ok(this
669 .wrapped_map
670 .get(&resource)
671 .map(|acl| AccessControlListWrap { wrapped_acl: acl }))
672 });
673
674 Memoized::impl_memoize(methods);
675 }
676}
677
678#[derive(Default)]
679pub struct AccessControlListMap {
680 map: HashMap<String, Arc<AccessControlList>>,
681}
682
683impl AccessControlListMap {
684 pub fn get(&self, resource: &str) -> Option<Arc<AccessControlList>> {
685 self.map.get(resource).cloned()
686 }
687
688 pub fn compiled_default() -> Self {
689 let acl_file: ACLFile = toml::from_str(include_str!("../../../assets/acls/default.toml"))
690 .expect("default ACL is invalid!");
691 acl_file.build_map()
692 }
693}
694
695#[derive(Deserialize, Serialize, Debug, Clone)]
696#[serde(deny_unknown_fields)]
697struct ACLFile {
698 acl: HashMap<String, Vec<ACLFileEntry>>,
699}
700
701impl ACLFile {
702 fn build_map(self) -> AccessControlListMap {
703 let mut map = HashMap::new();
704
705 for (resource_id, entries) in self.acl {
706 let mut rules = vec![];
707 for entry in entries {
708 let access = if entry.allow {
709 Access::Allow
710 } else {
711 Access::Deny
712 };
713 for privilege in entry.privileges {
714 match &entry.condition {
715 ACLFileCondition::Identity(identity) => {
716 rules.push(AccessControlRule {
717 criteria: ACLRuleTerm::Identity(identity.clone()),
718 privilege,
719 access,
720 });
721 }
722 ACLFileCondition::Criteria(term) => {
723 rules.push(AccessControlRule {
724 criteria: term.clone(),
725 privilege,
726 access,
727 });
728 }
729 }
730 }
731 }
732 map.insert(resource_id, Arc::new(AccessControlList { rules }));
733 }
734
735 AccessControlListMap { map }
736 }
737}
738
739#[derive(Deserialize, Serialize, Debug, Clone)]
740#[serde(deny_unknown_fields)]
741struct ACLFileEntry {
742 allow: bool,
743 privileges: Vec<String>,
744 #[serde(flatten)]
745 condition: ACLFileCondition,
746}
747
748#[derive(Deserialize, Serialize, Debug, Clone)]
749enum ACLFileCondition {
750 #[serde(rename = "identity")]
751 Identity(ACLIdentity),
752 #[serde(rename = "criteria")]
753 Criteria(ACLRuleTerm),
754}
755
756pub fn register(lua: &Lua) -> anyhow::Result<()> {
757 let aaa_mod = get_or_create_sub_module(lua, "aaa")?;
758
759 aaa_mod.set(
760 "configure_acct_log",
761 lua.create_async_function(
762 move |_lua, params: SerdeWrappedValue<AuditLogParams>| async move {
763 params.init().await.map_err(any_err)
764 },
765 )?,
766 )?;
767
768 aaa_mod.set(
769 "set_acl_cache_ttl",
770 lua.create_function(move |lua, duration: mlua::Value| {
771 let duration: duration_serde::Wrap<Duration> = lua.from_value(duration)?;
772 ACL_CACHE_TTL_MS.store(
773 duration.into_inner().as_millis().try_into().map_err(|_| {
774 mlua::Error::external("set_acl_cache_ttl: duration is too large")
775 })?,
776 Ordering::Relaxed,
777 );
778 Ok(())
779 })?,
780 )?;
781 aaa_mod.set(
782 "set_check_cache_ttl",
783 lua.create_function(move |lua, duration: mlua::Value| {
784 let duration: duration_serde::Wrap<Duration> = lua.from_value(duration)?;
785 CHECK_CACHE_TTL_MS.store(
786 duration.into_inner().as_millis().try_into().map_err(|_| {
787 mlua::Error::external("set_check_cache_ttl: duration is too large")
788 })?,
789 Ordering::Relaxed,
790 );
791 Ok(())
792 })?,
793 )?;
794 aaa_mod.set(
795 "make_access_control_list",
796 lua.create_function(move |lua, rules: mlua::Value| {
797 let rules: Vec<AccessControlRule> = lua.from_value(rules)?;
798 let list = AccessControlList { rules };
799 Ok(AccessControlListWrap {
800 wrapped_acl: Arc::new(list),
801 })
802 })?,
803 )?;
804
805 aaa_mod.set(
806 "set_fall_back_to_acl_map",
807 lua.create_function(move |_lua, fall_back: bool| {
808 FALL_BACK_TO_ACL_MAP.store(fall_back, Ordering::Relaxed);
809 Ok(())
810 })?,
811 )?;
812
813 aaa_mod.set(
814 "load_acl_map",
815 lua.create_async_function(
816 move |_lua, acl_file: SerdeWrappedValue<KeySource>| async move {
817 let data = acl_file.get().await.map_err(any_err)?;
818 let acl_file: ACLFile = toml::from_slice(&data).map_err(|err| {
819 mlua::Error::external(format!("failed to parse acl map data: {err}"))
820 })?;
821 let map = AccessControlListMapWrap {
822 wrapped_map: Arc::new(acl_file.build_map()),
823 };
824 Ok(map)
825 },
826 )?,
827 )?;
828
829 #[derive(Clone)]
830 enum ResourceWrap {
831 HttpEndpoint(HttpEndpointResource),
832 }
833 impl LuaUserData for ResourceWrap {}
834 #[async_trait]
835 impl Resource for ResourceWrap {
836 fn resource_id(&self) -> &str {
837 match self {
838 Self::HttpEndpoint(r) => r.resource_id(),
839 }
840 }
841 async fn next_resource_id(&mut self) -> Option<String> {
842 match self {
843 Self::HttpEndpoint(r) => r.next_resource_id().await,
844 }
845 }
846 }
847
848 aaa_mod.set(
849 "make_http_url_resource",
850 lua.create_function(move |_lua, (local_addr, url): (String, String)| {
851 let local_addr: std::net::SocketAddr = local_addr.parse().map_err(any_err)?;
852 let url: Uri = url.parse().map_err(any_err)?;
853 let resource = HttpEndpointResource::new(local_addr, &url).map_err(any_err)?;
854 Ok(ResourceWrap::HttpEndpoint(resource))
855 })?,
856 )?;
857
858 aaa_mod.set(
859 "query_resource_access",
860 lua.create_async_function(
861 move |_lua,
862 (resource, info, privilege): (
863 UserDataRef<ResourceWrap>,
864 SerdeWrappedValue<AuthInfo>,
865 String,
866 )| async move {
867 let mut resource = resource.clone();
868 AccessControlList::query_resource_access(&mut resource, &info, &privilege)
869 .await
870 .map_err(any_err)
871 },
872 )?,
873 )?;
874
875 Ok(())
876}
877
878#[cfg(test)]
879mod test {
880 use super::*;
881 use std::collections::BTreeMap;
882
883 #[test]
884 fn verify_compiled_in_acl_compiles() {
885 LazyLock::force(&ACL_MAP);
886 }
887
888 #[test]
889 fn acl_file_syntax_critiera() {
890 let _: ACLFile = toml::from_str(
891 r#"
892[[acl."http_listener"]]
893allow = true
894privileges = ["GET"]
895[[acl."http_listener".criteria.AllOf]]
896Identity.Authenticated = {}
897[[acl."http_listener".criteria.AllOf]]
898Identity.Machine = "10.0.0.1"
899
900# Alternative criteria syntax
901[[acl."http_listener/*/api/inject"]]
902allow = true
903privileges = ["POST"]
904criteria.AllOf = [{Identity={Individual = "hunter"}}, {Identity={Machine = "10.0.0.1"}}]
905 "#,
906 )
907 .unwrap();
908 }
909
910 #[test]
911 fn acl_file_syntax() {
912 let acl_file: ACLFile = toml::from_str(
913 r#"
914[[acl."http_listener"]]
915allow = true
916privileges = ["GET", "PUT"]
917identity.Individual = "user"
918
919[[acl."http_listener"]]
920allow = false
921privileges = ["POST"]
922identity.Group = "group"
923
924[[acl."http_listener"]]
925allow = true
926privileges = ["GET"]
927identity.Machine = "10.0.0.1"
928
929[[acl."http_listener"]]
930allow = true
931privileges = ["PUT"]
932identity.MachineSet = ["10.0.0.1", "1.2.3.4"]
933
934[[acl."http_listener"]]
935allow = true
936privileges = ["GET"]
937identity.Unauthenticated = {}
938 "#,
939 )
940 .unwrap();
941
942 let ordered = acl_file
943 .build_map()
944 .map
945 .into_iter()
946 .collect::<BTreeMap<_, _>>();
947
948 k9::snapshot!(
949 ordered,
950 r#"
951{
952 "http_listener": AccessControlList {
953 rules: [
954 AccessControlRule {
955 criteria: Identity(
956 Individual(
957 "user",
958 ),
959 ),
960 privilege: "GET",
961 access: Allow,
962 },
963 AccessControlRule {
964 criteria: Identity(
965 Individual(
966 "user",
967 ),
968 ),
969 privilege: "PUT",
970 access: Allow,
971 },
972 AccessControlRule {
973 criteria: Identity(
974 Group(
975 "group",
976 ),
977 ),
978 privilege: "POST",
979 access: Deny,
980 },
981 AccessControlRule {
982 criteria: Identity(
983 Machine(
984 10.0.0.1,
985 ),
986 ),
987 privilege: "GET",
988 access: Allow,
989 },
990 AccessControlRule {
991 criteria: Identity(
992 MachineSet(
993 {
994 "1.2.3.4",
995 "10.0.0.1",
996 },
997 ),
998 ),
999 privilege: "PUT",
1000 access: Allow,
1001 },
1002 AccessControlRule {
1003 criteria: Identity(
1004 Unauthenticated,
1005 ),
1006 privilege: "GET",
1007 access: Allow,
1008 },
1009 ],
1010 },
1011}
1012"#
1013 );
1014 }
1015}