kumo_server_common/
authn_authz.rs

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// TODO:
44//  * Formalize object mapping. For example, in kumod we should map
45//    scheduled queues to some kind of object hierarchy. The default should
46//    be something like:
47//       scheduled_queues (as a container for all scheduled queues)
48//          tenant_<tenant>_queues (as a container for all of a tenant's queues)
49//             tenant_<tenant>_<campaign>_queues (contains each of the tenant's campaign queues
50//                <queue_name> - a queue inside a tenant-campaign
51//             <queue_name> - a queue inside a tenant but not a campaign
52//          campaign_<campaign>_queues (contains queues that have campaign but not tenant set)
53//             <queue_name> - a queue inside a campaign but not a tenant
54//          <queue_name> - a queue that has no tenant nor campaign
55//
56//    with those resources mapped out, we can then define ACLs with hierarchy.
57//    eg: `mailops` group has `queue:flush`, `queue:inspect`, `queue:summarize`
58//    privs set to allow on `scheduled_queues` at the top, granting those privs
59//    to all queues
60//
61//    `customer_xyz` group has `queue:summarize` and `queue:relay` set on
62//    `tenant_xyz_queues`, allowing those privs to just their tenant.
63//
64//    Need to sit down and think about what specific privileges these are
65//    for the different sorts of objects, because those will need to be encoded
66//    in the rust logic in a number of cases.  eg: in the smtp server, we'll
67//    need to check any privs that we define that control assignment prior to
68//    enqueuing the message.
69//
70//  * Some customers piggy-back on our tenant and/or campaign identifies to further
71//    sub-divide the queue space, so we need to allow them a way to influence
72//    that object mapping.  eg: one encodes `pool_tenant` in the campaign identifier.
73//    Is that neutral wrt. access control?
74//
75//  * Sanity check that we can apply this model to the things that matter:
76//     * mailops doing queue flushing, bouncing, inspecting
77//     * mailops summarizing queue stats
78//     * smtp client being able to inject mail at all
79//     * smtp client being able to relay to a specific destination. OR: tenant
80//       being allowed to relay to a specific destination.
81//     * smtp client being able to assign a specific tenant
82//     * http client being able to use injection API
83//     * smtp/http client being able to set a specific meta item
84//     * smtp/http client OR tenant having access to a dkim/arc key for signing purposes
85//     * machine being allowed to do message transfer
86//     * socks proxy client being allowed to use a specific source address
87//     * socks proxy client being allowed to connect to a specific destination address
88
89#[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    /// The peer machine, if known
109    #[serde(default)]
110    pub peer_address: Option<IpAddr>,
111    /// Authenticated identities
112    #[serde(default)]
113    pub identities: Vec<Identity>,
114    /// Any groups to which we might belong
115    #[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    /// Authentication that represents the local system taking action.
127    /// It should be considered to be the most privileged identity
128    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    /// Summarize the info in a form that should be reasonable to report
157    /// back to a connected peer. This form doesn't include any groups
158    /// that may have been added by the server and/or via policy
159    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    /// Merges groups and identities from other, ignoring its peer_address
181    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    /// Add an identity to the list
187    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    /// Set the peer address, ignoring and replacing any prior value
196    pub fn set_peer_address(&mut self, peer_address: Option<IpAddr>) {
197        self.peer_address = peer_address;
198    }
199
200    /// Test to see if this AuthInfo matches a specific identity from an ACL entry
201    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    /// When the event occurred
272    pub timestamp: DateTime<Utc>,
273    /// The resource being accessed
274    pub target_resource: String,
275    /// The privilege requested
276    pub privilege: String,
277    /// Was access granted or denied?
278    pub access: Access,
279    /// The resource whose ACL provided the decision.
280    /// If None, no rules matched and the decision was to deny by default.
281    pub matching_resource: Option<String>,
282    /// A structured representation of the matching ACL rule
283    pub rule: Option<AccessControlRule>,
284    /// A copy of the authentication information used to make the decision
285    pub auth_info: AuthInfo,
286    /// A list of the resource ids that were considered prior to reaching
287    /// this final disposition; these will be on the inheritance path
288    /// between the target_resource and the matching_resource (or the root,
289    /// if there was no matching_resource).
290    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/// An access control list for a specific resource
348#[derive(Clone, Debug, Serialize, Deserialize)]
349pub struct AccessControlList {
350    /// An ordered set of rules; the first matching rule
351    /// defines the allowed access level.  If we run
352    /// out of rules, then the caller should proceed to evaluate the
353    /// ACL for the parent resource, if any.
354    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    /// An explicit allow rule matched
494    Allow {
495        resource: String,
496        rule: AccessControlRule,
497    },
498    /// An explicit Deny rule matched
499    Deny {
500        resource: String,
501        rule: AccessControlRule,
502    },
503    /// Exhausted the list of rules for this resource,
504    /// so the behavior is to deny as a default.
505    /// If there is a parent resource, the caller should
506    /// proceed to query that one.
507    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    /// An individual identity
533    Individual(String),
534    /// A Group
535    Group(String),
536    /// A machine
537    Machine(IpAddr),
538    /// A set of machines
539    MachineSet(CidrSet),
540    /// wildcard to match any explicitly authenticated individual
541    Authenticated,
542    /// wildcard to match any unauthenticated session
543    Unauthenticated,
544    /// wildcard to match any thing
545    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/// This trait represents a Resource against which we'd like
632/// to perform an access control check.
633/// Each call to next_resource_id() will produce the identifier
634/// of the resource which is being checked, following its
635/// child -> parent relationship.
636///
637/// So a hypothetical filesystem path resource of `/foo/bar/baz`
638/// would return on each successive call:
639///
640/// `Some("/foo/bar/baz")`
641/// `Some("/foo/bar")`
642/// `Some("/foo")`
643/// `Some("/")`
644/// `None`
645///
646/// so that the ACL checker knows how to resolve the resources
647/// up to their containing root.
648///
649/// This trait is essentially an asynchronous iterator.
650#[async_trait]
651pub trait Resource {
652    /// Returns the resource to which access is desired
653    fn resource_id(&self) -> &str;
654    /// Returns the next resource in the inheritance hierarchy;
655    /// starts with the targeted resource_id and walks through
656    /// its parents.
657    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}