ldap-acis 0.2.1

LDAP Access Control Instructions (ACI) system built on acls-rs
Documentation
//! DN + scope region of the Directory Information Tree.

use super::Scope;
use crate::dn::{is_descendant_or_self, is_direct_child};

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

use std::cmp::Ordering;

/// A region of the Directory Information Tree defined by a base DN and scope.
///
/// Pairs a `target_dn` with a `Scope` to represent the set of entries an ACI
/// covers. Conservatively partially ordered by set containment:
/// - `(None, _)` is greatest (covers entire DIT)
/// - Same DN, `Subtree` scope covers `Base` and `OneLevel` at that DN
/// - Ancestor DN with `Subtree` covers any descendant's region
/// - `Base` and `OneLevel` at the same DN are incomparable (disjoint entry sets)
/// - Wildcard or `$SUFFIX` DNs are conservatively incomparable
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DnScope {
    /// Target DN (preserved as-is for formatting and interpolation), or `None` for unrestricted.
    pub dn: Option<String>,
    /// How deep below the target DN the region extends.
    pub scope: Scope,
}

impl DnScope {
    /// Create a new DnScope, preserving the DN string as-is.
    /// When `dn` is `None`, scope is normalized to `Subtree` since it is irrelevant.
    pub fn new(dn: Option<String>, scope: Scope) -> Self {
        match dn {
            None => Self {
                dn: None,
                scope: Scope::Subtree,
            },
            Some(d) => Self { dn: Some(d), scope },
        }
    }

    /// Unrestricted scope covering the entire DIT.
    pub fn unrestricted() -> Self {
        Self {
            dn: None,
            scope: Scope::Subtree,
        }
    }

    fn has_wildcard(dn: &str) -> bool {
        crate::dn::has_wildcard(dn)
    }

    /// Whether a specific DN falls within this region.
    pub fn contains_dn(&self, dn: &str) -> bool {
        match &self.dn {
            None => true,
            Some(base) => match self.scope {
                Scope::Base => dn.eq_ignore_ascii_case(base),
                Scope::OneLevel => is_direct_child(dn, base),
                Scope::Subtree => is_descendant_or_self(dn, base),
            },
        }
    }

    /// Whether this region contains all entries in `other`'s region.
    fn contains_region(&self, other: &Self) -> bool {
        match (&self.dn, &other.dn) {
            (None, _) => true,
            (Some(_), None) => false,
            (Some(a), Some(b)) => {
                if a.eq_ignore_ascii_case(b) {
                    matches!(
                        (self.scope, other.scope),
                        (Scope::Subtree, _)
                            | (Scope::OneLevel, Scope::OneLevel)
                            | (Scope::Base, Scope::Base)
                    )
                } else {
                    if Self::has_wildcard(a) || Self::has_wildcard(b) {
                        return false;
                    }
                    if is_descendant_or_self(b, a) {
                        match self.scope {
                            Scope::Subtree => true,
                            Scope::OneLevel => other.scope == Scope::Base && is_direct_child(b, a),
                            Scope::Base => false,
                        }
                    } else {
                        false
                    }
                }
            }
        }
    }
}

impl PartialOrd for DnScope {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self == other {
            return Some(Ordering::Equal);
        }

        let self_contains_other = self.contains_region(other);
        let other_contains_self = other.contains_region(self);

        match (self_contains_other, other_contains_self) {
            (true, true) => Some(Ordering::Equal),
            (true, false) => Some(Ordering::Greater),
            (false, true) => Some(Ordering::Less),
            (false, false) => None,
        }
    }
}

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

    fn ds(dn: Option<&str>, scope: Scope) -> DnScope {
        DnScope::new(dn.map(|s| s.to_string()), scope)
    }

    #[test]
    fn unrestricted_is_greatest() {
        let unr = DnScope::unrestricted();
        assert!(unr > ds(Some("dc=example,dc=com"), Scope::Subtree));
        assert!(unr > ds(Some("ou=people,dc=example,dc=com"), Scope::Base));
    }

    #[test]
    fn unrestricted_normalized() {
        let a = ds(None, Scope::Base);
        let b = ds(None, Scope::Subtree);
        assert_eq!(a, b);
        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
    }

    #[test]
    fn same_dn_subtree_covers_all() {
        let sub = ds(Some("dc=example,dc=com"), Scope::Subtree);
        let one = ds(Some("dc=example,dc=com"), Scope::OneLevel);
        let base = ds(Some("dc=example,dc=com"), Scope::Base);
        assert!(sub > one);
        assert!(sub > base);
    }

    #[test]
    fn same_dn_base_and_onelevel_incomparable() {
        let base = ds(Some("dc=example,dc=com"), Scope::Base);
        let one = ds(Some("dc=example,dc=com"), Scope::OneLevel);
        assert_eq!(base.partial_cmp(&one), None);
    }

    #[test]
    fn ancestor_subtree_covers_descendant() {
        let parent = ds(Some("dc=example,dc=com"), Scope::Subtree);
        let child_sub = ds(Some("ou=people,dc=example,dc=com"), Scope::Subtree);
        let child_one = ds(Some("ou=people,dc=example,dc=com"), Scope::OneLevel);
        let child_base = ds(Some("ou=people,dc=example,dc=com"), Scope::Base);
        assert!(parent > child_sub);
        assert!(parent > child_one);
        assert!(parent > child_base);
    }

    #[test]
    fn ancestor_subtree_covers_deep_descendant() {
        let root = ds(Some("dc=example,dc=com"), Scope::Subtree);
        let deep = ds(Some("uid=alice,ou=people,dc=example,dc=com"), Scope::Base);
        assert!(root > deep);
    }

    #[test]
    fn ancestor_onelevel_covers_direct_child_base() {
        let parent = ds(Some("dc=example,dc=com"), Scope::OneLevel);
        let child = ds(Some("ou=people,dc=example,dc=com"), Scope::Base);
        assert!(parent > child);
    }

    #[test]
    fn ancestor_onelevel_does_not_cover_child_onelevel() {
        let parent = ds(Some("dc=example,dc=com"), Scope::OneLevel);
        let child = ds(Some("ou=people,dc=example,dc=com"), Scope::OneLevel);
        assert_eq!(parent.partial_cmp(&child), None);
    }

    #[test]
    fn ancestor_onelevel_does_not_cover_grandchild() {
        let grandparent = ds(Some("dc=example,dc=com"), Scope::OneLevel);
        let grandchild = ds(Some("uid=alice,ou=people,dc=example,dc=com"), Scope::Base);
        assert_eq!(grandparent.partial_cmp(&grandchild), None);
    }

    #[test]
    fn ancestor_base_does_not_cover_child() {
        let parent = ds(Some("dc=example,dc=com"), Scope::Base);
        let child = ds(Some("ou=people,dc=example,dc=com"), Scope::Base);
        assert_eq!(parent.partial_cmp(&child), None);
    }

    #[test]
    fn sibling_dns_incomparable() {
        let a = ds(Some("ou=people,dc=example,dc=com"), Scope::Subtree);
        let b = ds(Some("ou=groups,dc=example,dc=com"), Scope::Subtree);
        assert_eq!(a.partial_cmp(&b), None);
    }

    #[test]
    fn wildcard_dns_incomparable() {
        let wild = ds(Some("uid=*,ou=people,dc=example,dc=com"), Scope::Subtree);
        let concrete = ds(Some("ou=people,dc=example,dc=com"), Scope::Subtree);
        assert_eq!(wild.partial_cmp(&concrete), None);
    }

    #[test]
    fn same_wildcard_dn_scope_comparison() {
        let a = ds(Some("uid=*,dc=example,dc=com"), Scope::Subtree);
        let b = ds(Some("uid=*,dc=example,dc=com"), Scope::Base);
        assert!(a > b);
    }

    #[test]
    fn case_insensitive_dn() {
        let a = ds(Some("DC=EXAMPLE,DC=COM"), Scope::Subtree);
        let b = ds(Some("dc=example,dc=com"), Scope::Subtree);
        assert_ne!(a, b);
        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
        assert!(a.contains_dn("dc=example,dc=com"));
        assert!(b.contains_dn("DC=EXAMPLE,DC=COM"));
    }

    #[test]
    fn reflexive() {
        let scopes = [
            DnScope::unrestricted(),
            ds(Some("dc=example,dc=com"), Scope::Base),
            ds(Some("dc=example,dc=com"), Scope::OneLevel),
            ds(Some("dc=example,dc=com"), Scope::Subtree),
            ds(Some("uid=*,dc=example,dc=com"), Scope::Subtree),
        ];
        for s in &scopes {
            assert_eq!(s.partial_cmp(s), Some(Ordering::Equal));
        }
    }

    #[test]
    fn aci_dn_scope_accessor() {
        use super::super::{AciBuilder, Scope};

        let aci = AciBuilder::new("test")
            .target_dn("ou=People,dc=Example,dc=COM")
            .target_scope(Scope::OneLevel)
            .permission(OperationType::Read)
            .build();
        let scope = aci.dn_scope();
        assert_eq!(scope.dn, Some("ou=People,dc=Example,dc=COM".to_string()));
        assert_eq!(scope.scope, Scope::OneLevel);
        assert!(scope.contains_dn("uid=alice,ou=people,dc=example,dc=com"));
    }
}