ldap-acis 0.2.1

LDAP Access Control Instructions (ACI) system built on acls-rs
Documentation
//! Target filter for ACIs.

use crate::entry::LdapEntry;

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use std::cmp::Ordering;

/// Target filter for ACIs.
///
/// Conservatively partially ordered by "matches more entries" (wider = greater):
/// - `All` is greatest
/// - `And` narrows (more conjuncts = less coverage)
/// - `Or` widens (more disjuncts = more coverage)
/// - `Raw` is incomparable with everything (opaque)
/// - Different leaf variants are incomparable without schema knowledge
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum TargetFilter {
    /// Match all entries
    All,
    /// Match DN pattern (supports wildcards)
    DnPattern(String),
    /// Match entries with specific object class
    ObjectClass(String),
    /// Match entries with specific attribute
    HasAttribute(String),
    /// Combination of filters (AND)
    And(Vec<TargetFilter>),
    /// Combination of filters (OR)
    Or(Vec<TargetFilter>),
    /// Negation filter — matches entries that do NOT match the inner filter
    Not(Box<TargetFilter>),
    /// Opaque LDAP filter string, passed through verbatim in generation.
    Raw(String),
}

impl TargetFilter {
    /// Check if an entry matches this filter.
    pub fn matches(&self, entry: &LdapEntry) -> bool {
        match self {
            Self::All => true,
            Self::DnPattern(pattern) => entry.dn_matches(pattern),
            Self::ObjectClass(oc) => entry.has_object_class(oc),
            Self::HasAttribute(attr) => entry.attributes.contains_key(&attr.to_lowercase()),
            Self::And(filters) => filters.iter().all(|f| f.matches(entry)),
            Self::Or(filters) => filters.iter().any(|f| f.matches(entry)),
            Self::Not(inner) => !inner.matches(entry),
            Self::Raw(_) => false,
        }
    }

    /// Whether this filter contains a `!(memberOf=<group_dn>)` negation
    /// that excludes members of the given group.
    pub fn has_member_exclusion(&self, group_dn: &str) -> bool {
        match self {
            Self::And(filters) => filters.iter().any(|f| f.has_member_exclusion(group_dn)),
            Self::Or(filters) => filters.iter().all(|f| f.has_member_exclusion(group_dn)),
            Self::Not(inner) => inner.is_member_of_filter(group_dn),
            _ => false,
        }
    }

    /// Whether this filter contains a `Not(inner)` where `inner` matches
    /// the given pattern (case-insensitive substring on `Raw` variants).
    /// Generalizes `has_member_exclusion` to arbitrary filter negations,
    /// e.g. `has_filter_exclusion("ipaPermissionType=SYSTEM")`.
    pub fn has_filter_exclusion(&self, pattern: &str) -> bool {
        match self {
            Self::And(filters) => filters.iter().any(|f| f.has_filter_exclusion(pattern)),
            Self::Or(filters) => filters.iter().all(|f| f.has_filter_exclusion(pattern)),
            Self::Not(inner) => inner.matches_filter_pattern(pattern),
            _ => false,
        }
    }

    fn matches_filter_pattern(&self, pattern: &str) -> bool {
        let pattern_lower = pattern.to_lowercase();
        match self {
            Self::Raw(s) => s.to_lowercase().contains(&pattern_lower),
            Self::And(filters) | Self::Or(filters) => {
                filters.iter().any(|f| f.matches_filter_pattern(pattern))
            }
            Self::ObjectClass(oc) => {
                let check = format!("objectclass={}", oc).to_lowercase();
                check.contains(&pattern_lower) || pattern_lower.contains(&check)
            }
            Self::HasAttribute(attr) => {
                let check = format!("{}=", attr).to_lowercase();
                pattern_lower.contains(&check)
            }
            _ => false,
        }
    }

    /// Whether this filter is a `memberOf=<group_dn>` assertion.
    fn is_member_of_filter(&self, group_dn: &str) -> bool {
        let group_lower = group_dn.to_lowercase();
        match self {
            Self::Raw(s) => {
                let s_lower = s.to_lowercase();
                s_lower.contains("memberof=") && s_lower.contains(&group_lower)
            }
            Self::HasAttribute(attr) => attr.eq_ignore_ascii_case("memberof"),
            _ => false,
        }
    }
}

impl PartialOrd for TargetFilter {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self == other {
            return Some(Ordering::Equal);
        }
        match (self, other) {
            (Self::All, _) => Some(Ordering::Greater),
            (_, Self::All) => Some(Ordering::Less),

            (Self::Raw(_), _) | (_, Self::Raw(_)) => None,
            (Self::Not(_), _) | (_, Self::Not(_)) => None,

            (Self::And(a), Self::And(b)) => {
                let a_has_all_b = b.iter().all(|bi| a.contains(bi));
                let b_has_all_a = a.iter().all(|ai| b.contains(ai));
                match (a_has_all_b, b_has_all_a) {
                    (true, true) => Some(Ordering::Equal),
                    (true, false) => Some(Ordering::Less),
                    (false, true) => Some(Ordering::Greater),
                    (false, false) => None,
                }
            }

            (Self::Or(a), Self::Or(b)) => {
                let a_has_all_b = b.iter().all(|bi| a.contains(bi));
                let b_has_all_a = a.iter().all(|ai| b.contains(ai));
                match (a_has_all_b, b_has_all_a) {
                    (true, true) => Some(Ordering::Equal),
                    (true, false) => Some(Ordering::Greater),
                    (false, true) => Some(Ordering::Less),
                    (false, false) => None,
                }
            }

            (Self::And(items), x) if items.iter().any(|i| i == x) => Some(Ordering::Less),
            (x, Self::And(items)) if items.iter().any(|i| i == x) => Some(Ordering::Greater),

            (Self::Or(items), x) if items.iter().any(|i| i == x) => Some(Ordering::Greater),
            (x, Self::Or(items)) if items.iter().any(|i| i == x) => Some(Ordering::Less),

            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    mod target_filter_ordering {
        use super::*;

        #[test]
        fn all_is_greatest() {
            let filters = [
                TargetFilter::ObjectClass("posixAccount".into()),
                TargetFilter::DnPattern("uid=*,dc=example,dc=com".into()),
                TargetFilter::HasAttribute("uid".into()),
                TargetFilter::And(vec![
                    TargetFilter::ObjectClass("posixAccount".into()),
                    TargetFilter::HasAttribute("uid".into()),
                ]),
                TargetFilter::Or(vec![
                    TargetFilter::ObjectClass("posixAccount".into()),
                    TargetFilter::ObjectClass("inetOrgPerson".into()),
                ]),
            ];
            for f in &filters {
                assert!(TargetFilter::All > *f, "All should be greater than {:?}", f);
            }
        }

        #[test]
        fn reflexive() {
            let filters = [
                TargetFilter::All,
                TargetFilter::ObjectClass("posixAccount".into()),
                TargetFilter::Raw("(uid=*)".into()),
                TargetFilter::And(vec![TargetFilter::ObjectClass("x".into())]),
            ];
            for f in &filters {
                assert_eq!(f.partial_cmp(f), Some(Ordering::Equal));
            }
        }

        #[test]
        fn raw_is_incomparable() {
            let raw = TargetFilter::Raw("(uid=*)".into());
            let oc = TargetFilter::ObjectClass("posixAccount".into());
            assert_eq!(raw.partial_cmp(&oc), None);
            assert_eq!(oc.partial_cmp(&raw), None);
            assert_eq!(raw.partial_cmp(&TargetFilter::All), Some(Ordering::Less));
        }

        #[test]
        fn and_narrows() {
            let a = TargetFilter::ObjectClass("posixAccount".into());
            let b = TargetFilter::HasAttribute("uid".into());
            let and = TargetFilter::And(vec![a.clone(), b.clone()]);
            assert!(and < a, "And should be narrower than any conjunct");
            assert!(and < b);
        }

        #[test]
        fn or_widens() {
            let a = TargetFilter::ObjectClass("posixAccount".into());
            let b = TargetFilter::ObjectClass("inetOrgPerson".into());
            let or = TargetFilter::Or(vec![a.clone(), b.clone()]);
            assert!(or > a, "Or should be wider than any disjunct");
            assert!(or > b);
        }

        #[test]
        fn and_more_conjuncts_is_narrower() {
            let ab = TargetFilter::And(vec![
                TargetFilter::ObjectClass("a".into()),
                TargetFilter::ObjectClass("b".into()),
            ]);
            let abc = TargetFilter::And(vec![
                TargetFilter::ObjectClass("a".into()),
                TargetFilter::ObjectClass("b".into()),
                TargetFilter::ObjectClass("c".into()),
            ]);
            assert!(abc < ab, "More conjuncts = narrower = less");
        }

        #[test]
        fn or_more_disjuncts_is_wider() {
            let ab = TargetFilter::Or(vec![
                TargetFilter::ObjectClass("a".into()),
                TargetFilter::ObjectClass("b".into()),
            ]);
            let abc = TargetFilter::Or(vec![
                TargetFilter::ObjectClass("a".into()),
                TargetFilter::ObjectClass("b".into()),
                TargetFilter::ObjectClass("c".into()),
            ]);
            assert!(abc > ab, "More disjuncts = wider = greater");
        }

        #[test]
        fn different_leaves_incomparable() {
            let oc = TargetFilter::ObjectClass("posixAccount".into());
            let ha = TargetFilter::HasAttribute("uid".into());
            assert_eq!(oc.partial_cmp(&ha), None);
        }

        #[test]
        fn disjoint_ands_incomparable() {
            let and1 = TargetFilter::And(vec![TargetFilter::ObjectClass("a".into())]);
            let and2 = TargetFilter::And(vec![TargetFilter::ObjectClass("b".into())]);
            assert_eq!(and1.partial_cmp(&and2), None);
        }

        #[test]
        fn not_is_incomparable() {
            let not = TargetFilter::Not(Box::new(TargetFilter::ObjectClass("a".into())));
            let oc = TargetFilter::ObjectClass("b".into());
            assert_eq!(not.partial_cmp(&oc), None);
            assert_eq!(oc.partial_cmp(&not), None);
            assert!(not < TargetFilter::All);
        }
    }

    mod member_exclusion {
        use super::*;

        #[test]
        fn and_with_not_memberof_raw() {
            let filter = TargetFilter::And(vec![
                TargetFilter::Not(Box::new(TargetFilter::Raw(
                    "memberOf=cn=admins,cn=groups,cn=accounts,dc=example,dc=com".into(),
                ))),
                TargetFilter::ObjectClass("posixaccount".into()),
            ]);
            assert!(
                filter.has_member_exclusion("cn=admins,cn=groups,cn=accounts,dc=example,dc=com")
            );
            assert!(
                !filter.has_member_exclusion("cn=editors,cn=groups,cn=accounts,dc=example,dc=com")
            );
        }

        #[test]
        fn simple_objectclass_no_exclusion() {
            let filter = TargetFilter::ObjectClass("posixaccount".into());
            assert!(
                !filter.has_member_exclusion("cn=admins,cn=groups,cn=accounts,dc=example,dc=com")
            );
        }

        #[test]
        fn all_no_exclusion() {
            assert!(!TargetFilter::All.has_member_exclusion("cn=admins"));
        }

        #[test]
        fn case_insensitive_match() {
            let filter = TargetFilter::Not(Box::new(TargetFilter::Raw(
                "memberOf=CN=Admins,CN=Groups,CN=Accounts,DC=Example,DC=COM".into(),
            )));
            assert!(
                filter.has_member_exclusion("cn=admins,cn=groups,cn=accounts,dc=example,dc=com")
            );
        }

        #[test]
        fn or_requires_all_branches() {
            let filter = TargetFilter::Or(vec![
                TargetFilter::And(vec![
                    TargetFilter::Not(Box::new(TargetFilter::Raw("memberOf=cn=admins".into()))),
                    TargetFilter::ObjectClass("posixaccount".into()),
                ]),
                TargetFilter::ObjectClass("inetorgperson".into()),
            ]);
            assert!(!filter.has_member_exclusion("cn=admins"));
        }

        #[test]
        fn or_with_exclusion_in_all_branches() {
            let filter = TargetFilter::Or(vec![
                TargetFilter::Not(Box::new(TargetFilter::Raw("memberOf=cn=admins".into()))),
                TargetFilter::Not(Box::new(TargetFilter::Raw("memberOf=cn=admins".into()))),
            ]);
            assert!(filter.has_member_exclusion("cn=admins"));
        }
    }
}