ldap-acis 0.2.1

LDAP Access Control Instructions (ACI) system built on acls-rs
Documentation
use lru::LruCache;
use std::num::NonZeroUsize;

use crate::aci::{Aci, AuthorizationResult};
use crate::entry::LdapEntry;
use crate::operation::LdapOperation;
use crate::optimized::LdapCompiledEvaluator;

type CacheKey = u64;

struct PreHashedPassthrough(u64);

impl std::hash::Hasher for PreHashedPassthrough {
    fn finish(&self) -> u64 {
        self.0
    }
    fn write(&mut self, bytes: &[u8]) {
        for &b in bytes {
            self.0 = self.0.wrapping_mul(31).wrapping_add(b as u64);
        }
    }
    fn write_u64(&mut self, n: u64) {
        self.0 = n;
    }
}

#[derive(Clone, Default)]
struct PassthroughBuildHasher;

impl std::hash::BuildHasher for PassthroughBuildHasher {
    type Hasher = PreHashedPassthrough;
    fn build_hasher(&self) -> Self::Hasher {
        PreHashedPassthrough(0)
    }
}

pub const DEFAULT_CACHE_SIZE: usize = 1024;

pub struct CachedAciPolicy {
    evaluator: LdapCompiledEvaluator,
    check_cache: LruCache<CacheKey, bool, PassthroughBuildHasher>,
    authorize_cache: LruCache<CacheKey, bool, PassthroughBuildHasher>,
}

impl std::fmt::Debug for CachedAciPolicy {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CachedAciPolicy")
            .field("evaluator", &self.evaluator)
            .field("check_cache_len", &self.check_cache.len())
            .field("authorize_cache_len", &self.authorize_cache.len())
            .finish()
    }
}

impl CachedAciPolicy {
    pub fn new(acis: &[Aci]) -> Self {
        Self::with_cache_size(acis, DEFAULT_CACHE_SIZE)
    }

    pub fn with_cache_size(acis: &[Aci], cache_size: usize) -> Self {
        let size = NonZeroUsize::new(cache_size.max(1)).unwrap();
        Self {
            evaluator: LdapCompiledEvaluator::build(acis),
            check_cache: LruCache::with_hasher(size, PassthroughBuildHasher),
            authorize_cache: LruCache::with_hasher(size, PassthroughBuildHasher),
        }
    }

    pub fn check_access(
        &mut self,
        operation: &LdapOperation,
        target_entry: &LdapEntry,
        user_dn: Option<&str>,
        user_entry: Option<&LdapEntry>,
    ) -> bool {
        let cache_key = Self::build_cache_key(operation, &target_entry.dn, user_dn);

        if let Some(&allowed) = self.check_cache.get(&cache_key) {
            return allowed;
        }

        let allowed = self
            .evaluator
            .check_access(operation, target_entry, user_dn, user_entry);
        self.check_cache.put(cache_key, allowed);
        allowed
    }

    /// Cached authorize: returns full `AuthorizationResult` on miss,
    /// returns result with cached boolean and empty permission sets on hit.
    pub fn authorize(
        &mut self,
        operation: &LdapOperation,
        target_entry: &LdapEntry,
        user_dn: Option<&str>,
        user_entry: Option<&LdapEntry>,
    ) -> AuthorizationResult {
        let cache_key = Self::build_cache_key(operation, &target_entry.dn, user_dn);

        if let Some(&authorized) = self.authorize_cache.get(&cache_key) {
            return AuthorizationResult::cached(authorized);
        }

        let result = self
            .evaluator
            .authorize(operation, target_entry, user_dn, user_entry);
        self.authorize_cache.put(cache_key, result.authorized);
        result
    }

    /// Uncached authorize: always computes the full `AuthorizationResult`
    /// with grants, denials, and effective permissions.
    pub fn authorize_full(
        &mut self,
        operation: &LdapOperation,
        target_entry: &LdapEntry,
        user_dn: Option<&str>,
        user_entry: Option<&LdapEntry>,
    ) -> AuthorizationResult {
        self.evaluator
            .authorize(operation, target_entry, user_dn, user_entry)
    }

    /// Uncached check_access: always evaluates without consulting the cache.
    pub fn check_access_uncached(
        &self,
        operation: &LdapOperation,
        target_entry: &LdapEntry,
        user_dn: Option<&str>,
        user_entry: Option<&LdapEntry>,
    ) -> bool {
        self.evaluator
            .check_access(operation, target_entry, user_dn, user_entry)
    }

    pub fn clear_cache(&mut self) {
        self.check_cache.clear();
        self.authorize_cache.clear();
    }

    pub fn rule_count(&self) -> usize {
        self.evaluator.rule_count()
    }

    pub fn cache_len(&self) -> usize {
        self.check_cache.len() + self.authorize_cache.len()
    }

    fn build_cache_key(
        operation: &LdapOperation,
        target_dn: &str,
        user_dn: Option<&str>,
    ) -> CacheKey {
        use std::hash::{Hash, Hasher};
        let mut hasher = ahash::AHasher::default();
        (operation.operation_type as u8).hash(&mut hasher);
        target_dn.hash(&mut hasher);
        user_dn.hash(&mut hasher);
        operation.attributes.hash(&mut hasher);
        hasher.finish()
    }
}

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

    #[test]
    fn check_access_cache_hit() {
        let acis = vec![AciBuilder::new("read-people")
            .target_dn("ou=people,dc=example,dc=com")
            .target_scope(Scope::OneLevel)
            .permission(OperationType::Read)
            .bind_rule(BindRule::Authenticated)
            .build()];

        let mut cached = CachedAciPolicy::new(&acis);

        let entry = LdapEntry::new("uid=alice,ou=people,dc=example,dc=com");
        let op = LdapOperation::new(OperationType::Read, &entry.dn);
        let user_dn = Some("uid=bob,ou=people,dc=example,dc=com");

        let r1 = cached.check_access(&op, &entry, user_dn, None);
        assert_eq!(cached.check_cache.len(), 1);

        let r2 = cached.check_access(&op, &entry, user_dn, None);
        assert_eq!(r1, r2);
        assert_eq!(cached.check_cache.len(), 1);
    }

    #[test]
    fn authorize_cache_hit() {
        let acis = vec![AciBuilder::new("read-people")
            .target_dn("ou=people,dc=example,dc=com")
            .target_scope(Scope::OneLevel)
            .permission(OperationType::Read)
            .bind_rule(BindRule::Authenticated)
            .build()];

        let mut cached = CachedAciPolicy::new(&acis);

        let entry = LdapEntry::new("uid=alice,ou=people,dc=example,dc=com");
        let op = LdapOperation::new(OperationType::Read, &entry.dn);
        let user_dn = Some("uid=bob,ou=people,dc=example,dc=com");

        let r1 = cached.authorize(&op, &entry, user_dn, None);
        assert_eq!(cached.authorize_cache.len(), 1);
        assert!(r1.authorized);

        let r2 = cached.authorize(&op, &entry, user_dn, None);
        assert_eq!(r1.authorized, r2.authorized);
        assert_eq!(cached.authorize_cache.len(), 1);
        // Cached result has empty permission sets
        assert!(r2.grants.is_empty());
        assert!(r2.denials.is_empty());
    }

    #[test]
    fn authorize_full_bypasses_cache() {
        let acis = vec![AciBuilder::new("read-people")
            .target_dn("ou=people,dc=example,dc=com")
            .target_scope(Scope::OneLevel)
            .permission(OperationType::Read)
            .bind_rule(BindRule::Authenticated)
            .build()];

        let mut cached = CachedAciPolicy::new(&acis);

        let entry = LdapEntry::new("uid=alice,ou=people,dc=example,dc=com");
        let op = LdapOperation::new(OperationType::Read, &entry.dn);
        let user_dn = Some("uid=bob,ou=people,dc=example,dc=com");

        let r1 = cached.authorize_full(&op, &entry, user_dn, None);
        assert_eq!(cached.authorize_cache.len(), 0);
        assert!(r1.authorized);
        assert!(!r1.effective.is_empty());
    }

    #[test]
    fn different_ops_different_keys() {
        let acis = vec![AciBuilder::new("read-all")
            .permission(OperationType::Read)
            .bind_rule(BindRule::Anyone)
            .build()];

        let mut cached = CachedAciPolicy::new(&acis);

        let entry = LdapEntry::new("dc=example,dc=com");
        let read_op = LdapOperation::new(OperationType::Read, &entry.dn);
        let write_op = LdapOperation::new(OperationType::Modify, &entry.dn);

        cached.check_access(&read_op, &entry, None, None);
        cached.check_access(&write_op, &entry, None, None);
        assert_eq!(cached.check_cache.len(), 2);
    }

    #[test]
    fn different_attributes_different_keys() {
        let acis = vec![
            AciBuilder::new("allow-read")
                .permission(OperationType::Read)
                .bind_rule(BindRule::Anyone)
                .build(),
            AciBuilder::new("deny-password")
                .target_attribute("userpassword")
                .permission(OperationType::Read)
                .bind_rule(BindRule::Anyone)
                .deny()
                .build(),
        ];

        let mut cached = CachedAciPolicy::new(&acis);
        let entry = LdapEntry::new("dc=example,dc=com");

        let op_cn = LdapOperation::new(OperationType::Read, &entry.dn)
            .with_attributes(vec!["cn".to_string()]);
        let op_pw = LdapOperation::new(OperationType::Read, &entry.dn)
            .with_attributes(vec!["userpassword".to_string()]);

        let r1 = cached.check_access(&op_cn, &entry, None, None);
        let r2 = cached.check_access(&op_pw, &entry, None, None);
        assert_eq!(cached.check_cache.len(), 2);
        assert_ne!(
            r1, r2,
            "cn and userpassword should produce different results"
        );
    }

    #[test]
    fn clear_cache_works() {
        let acis = vec![AciBuilder::new("read-all")
            .permission(OperationType::Read)
            .bind_rule(BindRule::Anyone)
            .build()];

        let mut cached = CachedAciPolicy::new(&acis);
        let entry = LdapEntry::new("dc=example,dc=com");
        let op = LdapOperation::new(OperationType::Read, &entry.dn);
        cached.check_access(&op, &entry, None, None);
        cached.authorize(&op, &entry, None, None);
        assert_eq!(cached.cache_len(), 2);

        cached.clear_cache();
        assert_eq!(cached.cache_len(), 0);
    }
}