ldap-acis 0.2.1

LDAP Access Control Instructions (ACI) system built on acls-rs
Documentation
//! Bind rules — who can use an ACI.

use super::CompOp;
use crate::dn::matches_dn_pattern;
use crate::entry::LdapEntry;

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

use std::cmp::Ordering;

/// Bind rule - who can use this ACI.
///
/// Conservatively partially ordered by "matches more principals" (wider = greater):
/// - `Anyone` is greatest (matches all principals including anonymous)
/// - `Authenticated` is below `Anyone` but above any identity-specific rule
/// - `And` narrows, `Or` widens, `Not` inverts
/// - Different identity rules (UserDn vs GroupDn) are incomparable
/// - Environment rules (Ip, Dns, Ssf, etc.) are incomparable with identity rules
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub enum BindRule {
    /// Anyone (anonymous)
    Anyone,
    /// Any authenticated user
    Authenticated,
    /// Specific user DN
    UserDn(String),
    /// User accessing their own entry
    SelfUser,
    /// Members of a group
    GroupDn(String),
    /// User with specific role attribute
    RoleAttribute(String, String),
    /// User is parent of target entry (userdn = "ldap:///parent")
    ParentDn,
    /// User has a 389-ds managed role (roledn = "ldap:///cn=role,...")
    RoleDn(String),
    /// DN-attribute on target entry matches user DN (userdnattr = "manager")
    UserDnAttr(String),
    /// DN-attribute on target entry is a group containing user (groupdnattr = "owner")
    GroupDnAttr(String),
    /// Client IP address (ip = "192.168.*")
    Ip(String),
    /// Client DNS hostname (dns = "*.example.com")
    Dns(String),
    /// Authentication method (authmethod = "simple", "sasl GSSAPI", "none")
    AuthMethod(String),
    /// Day of week restriction (dayofweek = "mon,tue,wed")
    DayOfWeek(String),
    /// Time of day restriction with comparison operator (timeofday >= "0800")
    TimeOfDay(CompOp, String),
    /// Security strength factor (ssf >= "128")
    Ssf(CompOp, u32),
    /// Combination (AND)
    And(Vec<BindRule>),
    /// Combination (OR)
    Or(Vec<BindRule>),
    /// Negation (NOT)
    Not(Box<BindRule>),
}

impl BindRule {
    /// Check if a user matches this bind rule.
    pub fn matches(
        &self,
        user_dn: Option<&str>,
        user_entry: Option<&LdapEntry>,
        target_dn: &str,
    ) -> bool {
        match self {
            Self::Anyone => true,
            Self::Authenticated => user_dn.is_some(),
            Self::UserDn(dn) => user_dn.map(|u| u.eq_ignore_ascii_case(dn)).unwrap_or(false),
            Self::SelfUser => user_dn
                .map(|u| u.eq_ignore_ascii_case(target_dn))
                .unwrap_or(false),
            Self::GroupDn(group) => user_entry
                .and_then(|e| e.get_attr("memberOf"))
                .map(|groups| groups.iter().any(|g| matches_dn_pattern(g, group)))
                .unwrap_or(false),
            Self::RoleAttribute(attr, value) => user_entry
                .and_then(|e| e.get_attr(attr))
                .map(|values| values.contains(value))
                .unwrap_or(false),
            Self::ParentDn => {
                if let Some(user) = user_dn {
                    if let Some(comma_pos) = target_dn.find(',') {
                        user.eq_ignore_ascii_case(&target_dn[comma_pos + 1..])
                    } else {
                        false
                    }
                } else {
                    false
                }
            }
            Self::RoleDn(role) => user_entry
                .and_then(|e| e.get_attr("nsRole"))
                .map(|roles| roles.iter().any(|r| r.eq_ignore_ascii_case(role)))
                .unwrap_or(false),
            Self::UserDnAttr(attr) => user_entry
                .and_then(|e| e.get_attr(attr))
                .map(|vals| {
                    user_dn
                        .map(|u| vals.iter().any(|v| v.eq_ignore_ascii_case(u)))
                        .unwrap_or(false)
                })
                .unwrap_or(false),
            Self::GroupDnAttr(_)
            | Self::Ip(_)
            | Self::Dns(_)
            | Self::AuthMethod(_)
            | Self::DayOfWeek(_)
            | Self::TimeOfDay(_, _)
            | Self::Ssf(_, _) => false,
            Self::And(rules) => rules
                .iter()
                .all(|r| r.matches(user_dn, user_entry, target_dn)),
            Self::Or(rules) => rules
                .iter()
                .any(|r| r.matches(user_dn, user_entry, target_dn)),
            Self::Not(rule) => !rule.matches(user_dn, user_entry, target_dn),
        }
    }

    /// Whether this is an identity-based rule (constrains who the user is).
    pub fn is_identity_rule(&self) -> bool {
        matches!(
            self,
            Self::Anyone
                | Self::Authenticated
                | Self::UserDn(_)
                | Self::SelfUser
                | Self::GroupDn(_)
                | Self::RoleDn(_)
                | Self::RoleAttribute(_, _)
                | Self::ParentDn
                | Self::UserDnAttr(_)
                | Self::GroupDnAttr(_)
        )
    }

    /// Whether this is an environment-based rule (constrains context, not identity).
    pub fn is_environment_rule(&self) -> bool {
        matches!(
            self,
            Self::Ip(_)
                | Self::Dns(_)
                | Self::AuthMethod(_)
                | Self::DayOfWeek(_)
                | Self::TimeOfDay(_, _)
                | Self::Ssf(_, _)
        )
    }

    /// Whether this rule or any sub-rule is environment-based.
    /// Recursively walks `And`/`Or`/`Not` to find any leaf environment rule.
    pub fn contains_environment_rule(&self) -> bool {
        match self {
            Self::And(rules) | Self::Or(rules) => {
                rules.iter().any(|r| r.contains_environment_rule())
            }
            Self::Not(inner) => inner.contains_environment_rule(),
            other => other.is_environment_rule(),
        }
    }
}

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

            (Self::Authenticated, x) if x.is_identity_rule() => Some(Ordering::Greater),
            (x, Self::Authenticated) if x.is_identity_rule() => Some(Ordering::Less),

            (Self::Not(a), Self::Not(b)) => b.partial_cmp(a),

            (Self::Not(inner), _) if **inner == Self::Anyone => Some(Ordering::Less),
            (_, Self::Not(inner)) if **inner == Self::Anyone => Some(Ordering::Greater),

            (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::*;

    #[test]
    fn anyone_is_greatest() {
        let rules = [
            BindRule::Authenticated,
            BindRule::UserDn("uid=admin".into()),
            BindRule::SelfUser,
            BindRule::GroupDn("cn=admins".into()),
            BindRule::Ip("192.168.*".into()),
        ];
        for r in &rules {
            assert!(
                BindRule::Anyone > *r,
                "Anyone should be greater than {:?}",
                r
            );
        }
    }

    #[test]
    fn reflexive() {
        let rules = [
            BindRule::Anyone,
            BindRule::Authenticated,
            BindRule::UserDn("uid=test".into()),
            BindRule::SelfUser,
            BindRule::GroupDn("cn=group".into()),
            BindRule::Ip("10.0.0.*".into()),
        ];
        for r in &rules {
            assert_eq!(r.partial_cmp(r), Some(Ordering::Equal));
        }
    }

    #[test]
    fn authenticated_above_identity_rules() {
        assert!(BindRule::Authenticated > BindRule::UserDn("uid=x".into()));
        assert!(BindRule::Authenticated > BindRule::SelfUser);
        assert!(BindRule::Authenticated > BindRule::GroupDn("cn=g".into()));
        assert!(BindRule::Authenticated > BindRule::RoleDn("cn=r".into()));
        assert!(BindRule::Authenticated > BindRule::ParentDn);
    }

    #[test]
    fn authenticated_vs_environment_incomparable() {
        assert_eq!(
            BindRule::Authenticated.partial_cmp(&BindRule::Ip("10.0.0.*".into())),
            None
        );
        assert_eq!(
            BindRule::Authenticated.partial_cmp(&BindRule::Ssf(CompOp::GreaterEqual, 128)),
            None
        );
    }

    #[test]
    fn different_identity_rules_incomparable() {
        assert_eq!(
            BindRule::UserDn("uid=a".into()).partial_cmp(&BindRule::GroupDn("cn=g".into())),
            None
        );
        assert_eq!(
            BindRule::SelfUser.partial_cmp(&BindRule::UserDn("uid=x".into())),
            None
        );
    }

    #[test]
    fn identity_vs_environment_incomparable() {
        assert_eq!(
            BindRule::UserDn("uid=a".into()).partial_cmp(&BindRule::Ip("10.*".into())),
            None
        );
    }

    #[test]
    fn not_anyone_is_least() {
        let not_anyone = BindRule::Not(Box::new(BindRule::Anyone));
        assert!(not_anyone < BindRule::Authenticated);
        assert!(not_anyone < BindRule::UserDn("uid=x".into()));
        assert!(not_anyone < BindRule::Ip("10.*".into()));
    }

    #[test]
    fn not_reverses_order() {
        let not_auth = BindRule::Not(Box::new(BindRule::Authenticated));
        let not_user = BindRule::Not(Box::new(BindRule::UserDn("uid=x".into())));
        assert!(not_auth < not_user);
    }

    #[test]
    fn and_narrows() {
        let user = BindRule::UserDn("uid=x".into());
        let ip = BindRule::Ip("10.*".into());
        let and = BindRule::And(vec![user.clone(), ip.clone()]);
        assert!(and < user);
        assert!(and < ip);
    }

    #[test]
    fn or_widens() {
        let user = BindRule::UserDn("uid=x".into());
        let group = BindRule::GroupDn("cn=g".into());
        let or = BindRule::Or(vec![user.clone(), group.clone()]);
        assert!(or > user);
        assert!(or > group);
    }

    #[test]
    fn and_more_conjuncts_narrower() {
        let ab = BindRule::And(vec![
            BindRule::UserDn("uid=x".into()),
            BindRule::Ip("10.*".into()),
        ]);
        let abc = BindRule::And(vec![
            BindRule::UserDn("uid=x".into()),
            BindRule::Ip("10.*".into()),
            BindRule::Ssf(CompOp::GreaterEqual, 128),
        ]);
        assert!(abc < ab);
    }

    #[test]
    fn or_more_disjuncts_wider() {
        let ab = BindRule::Or(vec![
            BindRule::UserDn("uid=x".into()),
            BindRule::UserDn("uid=y".into()),
        ]);
        let abc = BindRule::Or(vec![
            BindRule::UserDn("uid=x".into()),
            BindRule::UserDn("uid=y".into()),
            BindRule::UserDn("uid=z".into()),
        ]);
        assert!(abc > ab);
    }

    #[test]
    fn classification() {
        assert!(BindRule::Anyone.is_identity_rule());
        assert!(BindRule::Authenticated.is_identity_rule());
        assert!(BindRule::SelfUser.is_identity_rule());
        assert!(BindRule::UserDn("uid=x".into()).is_identity_rule());
        assert!(BindRule::GroupDn("cn=g".into()).is_identity_rule());

        assert!(!BindRule::Anyone.is_environment_rule());
        assert!(BindRule::Ip("10.*".into()).is_environment_rule());
        assert!(BindRule::Dns("*.example.com".into()).is_environment_rule());
        assert!(BindRule::Ssf(CompOp::GreaterEqual, 128).is_environment_rule());
        assert!(BindRule::DayOfWeek("mon".into()).is_environment_rule());
    }

    #[test]
    fn chain_anyone_authenticated_user() {
        let anyone = BindRule::Anyone;
        let auth = BindRule::Authenticated;
        let user = BindRule::UserDn("uid=admin,dc=example,dc=com".into());
        assert!(anyone > auth);
        assert!(auth > user);
        assert!(anyone > user);
    }
}