ldap-acis 0.2.1

LDAP Access Control Instructions (ACI) system built on acls-rs
Documentation
//! Target attribute types and attribute set algebra.

use acls_rs::algebra::{BoundedJoinSemilattice, BoundedMeetSemilattice};
use acls_rs::prelude::*;

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

use std::cmp::Ordering;
use std::collections::BTreeSet;

/// Operation for a target attribute filter entry.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum TargetAttrFilterOp {
    Add,
    Del,
}

/// A single `attr:(filter)` entry within a `targattrfilters` clause.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TargetAttrFilter {
    pub op: TargetAttrFilterOp,
    pub attr: String,
    pub filter: String,
}

/// Set of LDAP attributes an ACI targets.
///
/// Ordered by "covers more attributes" (wider = greater):
/// - `All` is greatest (no restriction)
/// - `Include(A) ≤ Include(B)` iff `A ⊆ B`
/// - `Exclude(A) ≤ Exclude(B)` iff `A ⊇ B` (fewer exclusions = wider)
/// - `Include` vs `Exclude` are incomparable without universe knowledge
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum AttributeSet {
    /// No restriction — ACI applies to all attributes.
    All,
    /// ACI applies only to these attributes (`targetattr = "..."`).
    Include(BTreeSet<String>),
    /// ACI applies to all attributes except these (`targetattr != "..."`).
    Exclude(BTreeSet<String>),
}

impl AttributeSet {
    /// Build from the legacy two-field representation.
    pub fn from_vec(attrs: &[String], excluded: bool) -> Self {
        if attrs.is_empty() {
            return Self::All;
        }
        let set: BTreeSet<String> = attrs.iter().map(|s| s.to_lowercase()).collect();
        if excluded {
            Self::Exclude(set)
        } else {
            Self::Include(set)
        }
    }

    /// Whether this set covers all attributes (no restriction).
    pub fn is_all(&self) -> bool {
        matches!(self, Self::All)
    }

    /// Whether a specific attribute is covered by this set.
    pub fn contains_attr(&self, attr: &str) -> bool {
        let lower = attr.to_lowercase();
        match self {
            Self::All => true,
            Self::Include(set) => set.contains(&lower),
            Self::Exclude(set) => !set.contains(&lower),
        }
    }

    /// The listed attribute names (empty for `All`).
    pub fn attrs(&self) -> &BTreeSet<String> {
        static EMPTY: std::sync::LazyLock<BTreeSet<String>> =
            std::sync::LazyLock::new(BTreeSet::new);
        match self {
            Self::All => &EMPTY,
            Self::Include(set) | Self::Exclude(set) => set,
        }
    }

    /// Whether this is an exclusion set.
    pub fn is_excluded(&self) -> bool {
        matches!(self, Self::Exclude(_))
    }

    /// Return the attributes as a sorted Vec (for display/serialization).
    pub fn to_vec(&self) -> Vec<String> {
        match self {
            Self::All => Vec::new(),
            Self::Include(set) | Self::Exclude(set) => set.iter().cloned().collect(),
        }
    }
}

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

            (Self::Include(a), Self::Include(b)) => {
                let a_sub_b = a.is_subset(b);
                let b_sub_a = b.is_subset(a);
                match (a_sub_b, b_sub_a) {
                    (true, true) => Some(Ordering::Equal),
                    (true, false) => Some(Ordering::Less),
                    (false, true) => Some(Ordering::Greater),
                    (false, false) => None,
                }
            }

            (Self::Exclude(a), Self::Exclude(b)) => {
                let a_sub_b = a.is_subset(b);
                let b_sub_a = b.is_subset(a);
                match (a_sub_b, b_sub_a) {
                    (true, true) => Some(Ordering::Equal),
                    (true, false) => Some(Ordering::Greater),
                    (false, true) => Some(Ordering::Less),
                    (false, false) => None,
                }
            }

            (Self::Include(_), Self::Exclude(_)) | (Self::Exclude(_), Self::Include(_)) => None,
        }
    }
}

impl Semigroup for AttributeSet {
    fn combine(self, other: Self) -> Self {
        self.join(other)
    }
}

impl Monoid for AttributeSet {
    fn identity() -> Self {
        Self::Include(BTreeSet::new())
    }
}

impl MeetSemilattice for AttributeSet {
    fn meet(self, other: Self) -> Self {
        match (self, other) {
            (Self::All, x) | (x, Self::All) => x,
            (Self::Include(a), Self::Include(b)) => {
                Self::Include(a.intersection(&b).cloned().collect())
            }
            (Self::Exclude(a), Self::Exclude(b)) => Self::Exclude(a.union(&b).cloned().collect()),
            (Self::Include(inc), Self::Exclude(exc)) | (Self::Exclude(exc), Self::Include(inc)) => {
                Self::Include(inc.difference(&exc).cloned().collect())
            }
        }
    }
}

impl JoinSemilattice for AttributeSet {
    fn join(self, other: Self) -> Self {
        match (self, other) {
            (Self::All, _) | (_, Self::All) => Self::All,
            (Self::Include(a), Self::Include(b)) => Self::Include(a.union(&b).cloned().collect()),
            (Self::Exclude(a), Self::Exclude(b)) => {
                let result: BTreeSet<String> = a.intersection(&b).cloned().collect();
                if result.is_empty() {
                    Self::All
                } else {
                    Self::Exclude(result)
                }
            }
            (Self::Include(inc), Self::Exclude(exc)) | (Self::Exclude(exc), Self::Include(inc)) => {
                let remaining: BTreeSet<String> = exc.difference(&inc).cloned().collect();
                if remaining.is_empty() {
                    Self::All
                } else {
                    Self::Exclude(remaining)
                }
            }
        }
    }
}

impl BoundedMeetSemilattice for AttributeSet {
    fn top() -> Self {
        Self::All
    }
}

impl BoundedJoinSemilattice for AttributeSet {
    fn bottom() -> Self {
        Self::Include(BTreeSet::new())
    }
}

impl std::fmt::Display for AttributeSet {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::All => write!(f, "*"),
            Self::Include(set) => {
                write!(f, "{}", set.iter().cloned().collect::<Vec<_>>().join(", "))
            }
            Self::Exclude(set) => {
                write!(
                    f,
                    "all except: {}",
                    set.iter().cloned().collect::<Vec<_>>().join(", ")
                )
            }
        }
    }
}

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

    fn inc(attrs: &[&str]) -> AttributeSet {
        AttributeSet::Include(attrs.iter().map(|s| s.to_lowercase()).collect())
    }

    fn exc(attrs: &[&str]) -> AttributeSet {
        AttributeSet::Exclude(attrs.iter().map(|s| s.to_lowercase()).collect())
    }

    #[test]
    fn all_is_greatest() {
        assert!(AttributeSet::All > inc(&["cn"]));
        assert!(AttributeSet::All > exc(&["cn"]));
        assert!(AttributeSet::All > inc(&[]));
    }

    #[test]
    fn include_subset() {
        let small = inc(&["cn"]);
        let big = inc(&["cn", "sn"]);
        assert!(small < big);
    }

    #[test]
    fn include_equal() {
        let a = inc(&["cn", "sn"]);
        let b = inc(&["sn", "cn"]);
        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
    }

    #[test]
    fn include_incomparable() {
        let a = inc(&["cn"]);
        let b = inc(&["sn"]);
        assert_eq!(a.partial_cmp(&b), None);
    }

    #[test]
    fn exclude_fewer_is_wider() {
        let fewer = exc(&["cn"]);
        let more = exc(&["cn", "sn"]);
        assert!(fewer > more);
    }

    #[test]
    fn include_vs_exclude_incomparable() {
        assert_eq!(inc(&["cn"]).partial_cmp(&exc(&["sn"])), None);
    }

    #[test]
    fn empty_include_is_bottom() {
        let bottom = AttributeSet::bottom();
        assert_eq!(bottom, inc(&[]));
        for set in [inc(&["cn"]), inc(&["cn", "sn"]), AttributeSet::All] {
            assert_eq!(set.clone().join(bottom.clone()), set);
        }
    }

    #[test]
    fn meet_include_include() {
        let a = inc(&["cn", "sn"]);
        let b = inc(&["cn", "uid"]);
        assert_eq!(a.meet(b), inc(&["cn"]));
    }

    #[test]
    fn meet_exclude_exclude() {
        let a = exc(&["cn"]);
        let b = exc(&["sn"]);
        assert_eq!(a.meet(b), exc(&["cn", "sn"]));
    }

    #[test]
    fn meet_include_exclude() {
        let a = inc(&["cn", "sn", "uid"]);
        let b = exc(&["sn"]);
        assert_eq!(a.meet(b), inc(&["cn", "uid"]));
    }

    #[test]
    fn meet_all_identity() {
        let set = inc(&["cn", "sn"]);
        assert_eq!(set.clone().meet(AttributeSet::All), set);
    }

    #[test]
    fn join_include_include() {
        let a = inc(&["cn"]);
        let b = inc(&["sn"]);
        assert_eq!(a.join(b), inc(&["cn", "sn"]));
    }

    #[test]
    fn join_exclude_exclude() {
        let a = exc(&["cn", "sn"]);
        let b = exc(&["sn", "uid"]);
        assert_eq!(a.join(b), exc(&["sn"]));
    }

    #[test]
    fn join_include_exclude() {
        let a = inc(&["cn"]);
        let b = exc(&["sn"]);
        assert_eq!(a.join(b), exc(&["sn"]));
    }

    #[test]
    fn join_include_exclude_overlap() {
        let a = inc(&["cn"]);
        let b = exc(&["cn"]);
        assert_eq!(a.join(b), AttributeSet::All);
    }

    #[test]
    fn contains_attr() {
        assert!(AttributeSet::All.contains_attr("anything"));
        assert!(inc(&["cn", "sn"]).contains_attr("CN"));
        assert!(!inc(&["cn", "sn"]).contains_attr("uid"));
        assert!(!exc(&["cn"]).contains_attr("CN"));
        assert!(exc(&["cn"]).contains_attr("sn"));
    }

    #[test]
    fn from_vec_compat() {
        let set = AttributeSet::from_vec(&["CN".to_string(), "SN".to_string()], false);
        assert_eq!(set, inc(&["cn", "sn"]));

        let set = AttributeSet::from_vec(&["CN".to_string()], true);
        assert_eq!(set, exc(&["cn"]));

        let set = AttributeSet::from_vec(&[], false);
        assert_eq!(set, AttributeSet::All);
    }

    #[test]
    fn aci_attribute_set_accessor() {
        use super::super::AciBuilder;

        let aci = AciBuilder::new("test")
            .target_attribute("cn")
            .target_attribute("sn")
            .permission(OperationType::Read)
            .build();
        assert_eq!(aci.attribute_set(), inc(&["cn", "sn"]));
    }

    #[test]
    fn idempotence() {
        for set in [AttributeSet::All, inc(&["cn", "sn"]), exc(&["uid"])] {
            assert_eq!(set.clone().meet(set.clone()), set);
            assert_eq!(set.clone().join(set.clone()), set);
        }
    }
}