use ahash::AHashSet;
use crate::aci::{AttributeSet, BindRule, Scope, TargetFilter};
use crate::dn_index::{CandidateFilter, DnScopeIndex};
use crate::entry::LdapEntry;
#[cfg(test)]
use crate::operation::OperationType;
use crate::operation::{LdapOperation, OperationSet, NUM_CONCRETE_OPS};
#[derive(Debug, Clone)]
enum CompiledAttributeSet {
All,
Include(AHashSet<String>),
Exclude(AHashSet<String>),
}
impl CompiledAttributeSet {
fn from_attribute_set(attr_set: &AttributeSet) -> Self {
match attr_set {
AttributeSet::All => CompiledAttributeSet::All,
AttributeSet::Include(set) => {
CompiledAttributeSet::Include(set.iter().cloned().collect())
}
AttributeSet::Exclude(set) => {
CompiledAttributeSet::Exclude(set.iter().cloned().collect())
}
}
}
#[inline(always)]
fn contains_attr(&self, attr_lower: &str) -> bool {
match self {
Self::All => true,
Self::Include(set) => set.contains(attr_lower),
Self::Exclude(set) => !set.contains(attr_lower),
}
}
#[inline(always)]
fn is_excluded(&self) -> bool {
matches!(self, Self::Exclude(_))
}
}
#[derive(Debug, Clone, Copy)]
enum BindClass {
Anyone,
Authenticated,
SelfUser,
Dynamic,
}
#[derive(Debug, Clone)]
pub(crate) struct CompiledAci {
operation_set: OperationSet,
attribute_set: CompiledAttributeSet,
has_attributes: bool,
target_filter: TargetFilter,
bind_rule: BindRule,
bind_class: BindClass,
pub(crate) grant: bool,
}
impl CompiledAci {
fn from_aci(aci: &crate::aci::Aci) -> Self {
let attribute_set_raw = aci.attribute_set();
let bind_class = match &aci.bind_rule {
BindRule::Anyone => BindClass::Anyone,
BindRule::Authenticated => BindClass::Authenticated,
BindRule::SelfUser => BindClass::SelfUser,
_ => BindClass::Dynamic,
};
Self {
operation_set: aci.operation_set(),
attribute_set: CompiledAttributeSet::from_attribute_set(&attribute_set_raw),
has_attributes: !aci.target_attributes.is_empty(),
target_filter: aci.target_filter.clone(),
bind_rule: aci.bind_rule.clone(),
bind_class,
grant: aci.grant,
}
}
#[inline]
fn applies_to_target_skip_dn(
&self,
operation: &LdapOperation,
target_entry: &LdapEntry,
lowered_attrs: &[String],
) -> bool {
if !self.operation_set.contains(operation.operation_type) {
return false;
}
if !self.target_filter.matches(target_entry) {
return false;
}
self.check_attributes(lowered_attrs)
}
#[inline]
fn check_attributes(&self, lowered_attrs: &[String]) -> bool {
if self.has_attributes {
if lowered_attrs.is_empty() {
if self.attribute_set.is_excluded() {
return false;
}
} else if self.attribute_set.is_excluded() {
if !lowered_attrs
.iter()
.any(|a| self.attribute_set.contains_attr(a))
{
return false;
}
} else if !lowered_attrs
.iter()
.all(|a| self.attribute_set.contains_attr(a))
{
return false;
}
}
true
}
#[inline]
fn bind_matches(
&self,
user_dn: Option<&str>,
user_entry: Option<&LdapEntry>,
target_dn: &str,
) -> bool {
match self.bind_class {
BindClass::Anyone => true,
BindClass::Authenticated => user_dn.is_some(),
BindClass::SelfUser => user_dn.is_some_and(|u| u.eq_ignore_ascii_case(target_dn)),
BindClass::Dynamic => self.bind_rule.matches(user_dn, user_entry, target_dn),
}
}
}
#[derive(Debug, Clone)]
pub struct LdapCompiledEvaluator {
all_acis: Vec<CompiledAci>,
dn_index: DnScopeIndex,
always_deny: bool,
has_deny: bool,
per_op_universal_grant: [bool; NUM_CONCRETE_OPS],
}
impl LdapCompiledEvaluator {
pub fn build(acis: &[crate::aci::Aci]) -> Self {
let mut all_acis = Vec::with_capacity(acis.len());
let mut has_deny = false;
let mut has_allow = false;
let mut per_op_universal_grant = [false; NUM_CONCRETE_OPS];
for aci in acis {
let compiled = CompiledAci::from_aci(aci);
if compiled.grant {
has_allow = true;
} else {
has_deny = true;
}
all_acis.push(compiled);
if aci.grant
&& matches!(aci.target_filter, TargetFilter::All)
&& aci.target_dn.is_none()
&& aci.scope == Scope::Subtree
&& aci.target_attributes.is_empty()
&& matches!(aci.bind_rule, BindRule::Anyone)
{
let bits = aci.operation_set().raw_bits();
for (pos, slot) in per_op_universal_grant.iter_mut().enumerate() {
if bits & (1 << pos) != 0 {
*slot = true;
}
}
}
}
let always_deny = acis.is_empty() || !has_allow;
let dn_index = DnScopeIndex::build(acis.len(), |i| {
use crate::dn_index::AciIndexInfo;
let attr_info: Option<&[String]> =
if acis[i].target_attributes.is_empty() || acis[i].target_attributes_excluded {
None
} else {
Some(&acis[i].target_attributes)
};
let oc_filter = match &acis[i].target_filter {
TargetFilter::ObjectClass(oc) => Some(oc.as_str()),
_ => None,
};
let ud_bind = match &acis[i].bind_rule {
BindRule::UserDn(dn) => Some(dn.as_str()),
_ => None,
};
AciIndexInfo {
target_dn: acis[i].target_dn.as_deref(),
scope: acis[i].scope,
op_set: acis[i].operation_set(),
grant: acis[i].grant,
include_attrs: attr_info,
objectclass_filter: oc_filter,
userdn_bind: ud_bind,
}
});
Self {
all_acis,
dn_index,
always_deny,
has_deny,
per_op_universal_grant,
}
}
#[inline]
pub fn check_access(
&self,
operation: &LdapOperation,
target_entry: &LdapEntry,
user_dn: Option<&str>,
user_entry: Option<&LdapEntry>,
) -> bool {
if self.always_deny {
return false;
}
let op_idx = operation
.operation_type
.bit_index()
.expect("OperationType::All should not be used in evaluation");
if !self.has_deny && self.per_op_universal_grant[op_idx] {
return true;
}
let has_universal = self.per_op_universal_grant[op_idx];
let lowered_attrs = &operation.attributes;
if self.has_deny && has_universal {
let denies = self.dn_index.find_candidates(
&target_entry.dn,
operation.operation_type,
CandidateFilter::DenyOnly,
lowered_attrs,
&target_entry.object_classes,
user_dn,
);
for idx in denies.iter() {
let aci = &self.all_acis[idx];
if !aci.applies_to_target_skip_dn(operation, target_entry, lowered_attrs) {
continue;
}
if aci.bind_matches(user_dn, user_entry, &target_entry.dn) {
return false;
}
}
return true;
}
if self.has_deny {
let (grants, denies) = self.dn_index.find_candidates_split(
&target_entry.dn,
operation.operation_type,
lowered_attrs,
&target_entry.object_classes,
user_dn,
);
let mut has_implicit_grant = false;
for idx in denies.iter() {
let aci = &self.all_acis[idx];
if !aci.applies_to_target_skip_dn(operation, target_entry, lowered_attrs) {
continue;
}
if aci.bind_matches(user_dn, user_entry, &target_entry.dn) {
return false;
}
if let BindRule::Not(ref inner) = aci.bind_rule {
if matches!(**inner, BindRule::GroupDn(_) | BindRule::UserDn(_)) {
has_implicit_grant = true;
}
}
}
if has_implicit_grant {
return true;
}
for idx in grants.iter() {
let aci = &self.all_acis[idx];
if !aci.applies_to_target_skip_dn(operation, target_entry, lowered_attrs) {
continue;
}
if aci.bind_matches(user_dn, user_entry, &target_entry.dn) {
return true;
}
}
return false;
}
let grants = self.dn_index.find_candidates(
&target_entry.dn,
operation.operation_type,
CandidateFilter::GrantOnly,
lowered_attrs,
&target_entry.object_classes,
user_dn,
);
for idx in grants.iter() {
let aci = &self.all_acis[idx];
if !aci.applies_to_target_skip_dn(operation, target_entry, lowered_attrs) {
continue;
}
if aci.bind_matches(user_dn, user_entry, &target_entry.dn) {
return true;
}
}
false
}
pub fn authorize(
&self,
operation: &LdapOperation,
target_entry: &LdapEntry,
user_dn: Option<&str>,
user_entry: Option<&LdapEntry>,
) -> crate::aci::AuthorizationResult {
if self.always_deny {
return crate::aci::AuthorizationResult::cached(false);
}
let op_idx = operation
.operation_type
.bit_index()
.expect("OperationType::All should not be used in evaluation");
if !self.has_deny && self.per_op_universal_grant[op_idx] {
return crate::aci::AuthorizationResult::cached(true);
}
use acls_rs::prelude::*;
let lowered_attrs = &operation.attributes;
let candidates = self.dn_index.find_candidates(
&target_entry.dn,
operation.operation_type,
CandidateFilter::All,
lowered_attrs,
&target_entry.object_classes,
user_dn,
);
let mut has_grant = false;
let mut has_deny = false;
let mut has_implicit_grant = false;
for idx in candidates.iter() {
let compiled = &self.all_acis[idx];
if !compiled.applies_to_target_skip_dn(operation, target_entry, lowered_attrs) {
continue;
}
if compiled.bind_matches(user_dn, user_entry, &target_entry.dn) {
if compiled.grant {
has_grant = true;
} else {
has_deny = true;
}
} else if !compiled.grant {
if let BindRule::Not(ref inner) = compiled.bind_rule {
if matches!(**inner, BindRule::GroupDn(_) | BindRule::UserDn(_)) {
has_implicit_grant = true;
}
}
}
}
let op_perm = operation.to_permission().to_atomic();
let mut grants = PermissionSet::new();
let mut denials = PermissionSet::new();
if has_grant || has_implicit_grant {
grants.extend([op_perm.clone()]);
}
if has_deny {
denials.extend([op_perm.clone()]);
}
let gd_pair = GrantDenialPair::new(grants, denials);
let effective = gd_pair.effective_permissions();
let authorized = effective.contains(&op_perm);
crate::aci::AuthorizationResult {
authorized,
grants: gd_pair.grants,
denials: gd_pair.denials,
effective,
}
}
pub fn rule_count(&self) -> usize {
self.all_acis.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aci::{AciBuilder, AciPolicy};
fn sample_policy() -> (AciPolicy, Vec<crate::aci::Aci>) {
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(),
AciBuilder::new("deny-password")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.target_attribute("userpassword")
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.deny()
.build(),
AciBuilder::new("self-modify")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::OneLevel)
.target_attribute("mail")
.target_attribute("telephonenumber")
.permission(OperationType::Modify)
.bind_rule(BindRule::SelfUser)
.build(),
];
let mut policy = AciPolicy::new();
for aci in &acis {
policy.add_aci(aci.clone());
}
(policy, acis)
}
#[test]
fn compiled_matches_uncompiled() {
let (policy, acis) = sample_policy();
let compiled = LdapCompiledEvaluator::build(&acis);
let scenarios = vec![
(
LdapOperation::new(OperationType::Read, "uid=alice,ou=people,dc=example,dc=com"),
LdapEntry::new("uid=alice,ou=people,dc=example,dc=com"),
Some("uid=bob,ou=people,dc=example,dc=com"),
),
(
LdapOperation::new(
OperationType::Modify,
"uid=alice,ou=people,dc=example,dc=com",
),
LdapEntry::new("uid=alice,ou=people,dc=example,dc=com"),
Some("uid=alice,ou=people,dc=example,dc=com"),
),
(
LdapOperation::new(OperationType::Read, "cn=config"),
LdapEntry::new("cn=config"),
Some("uid=admin,dc=example,dc=com"),
),
(
LdapOperation::new(
OperationType::Delete,
"uid=alice,ou=people,dc=example,dc=com",
),
LdapEntry::new("uid=alice,ou=people,dc=example,dc=com"),
None,
),
];
for (op, entry, user_dn) in &scenarios {
let uncompiled = policy.authorize(op, entry, *user_dn, None);
let compiled_result = compiled.authorize(op, entry, *user_dn, None);
assert_eq!(
uncompiled.authorized, compiled_result.authorized,
"Mismatch for op={:?} target={} user={:?}",
op.operation_type, entry.dn, user_dn
);
}
}
#[test]
fn check_access_matches_authorize() {
let (_, acis) = sample_policy();
let compiled = LdapCompiledEvaluator::build(&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 result = compiled.authorize(&op, &entry, user_dn, None);
let access = compiled.check_access(&op, &entry, user_dn, None);
assert_eq!(result.authorized, access);
}
#[test]
fn deny_override() {
let acis = vec![
AciBuilder::new("allow-all")
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.build(),
AciBuilder::new("deny-anon")
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.deny()
.build(),
];
let compiled = LdapCompiledEvaluator::build(&acis);
let entry = LdapEntry::new("dc=example,dc=com");
let op = LdapOperation::new(OperationType::Read, &entry.dn);
assert!(!compiled.check_access(&op, &entry, None, None));
}
#[test]
fn no_rules_denies() {
let compiled = LdapCompiledEvaluator::build(&[]);
let entry = LdapEntry::new("dc=example,dc=com");
let op = LdapOperation::new(OperationType::Read, &entry.dn);
assert!(!compiled.check_access(&op, &entry, None, None));
}
#[test]
fn check_access_implicit_grant_not_group_deny() {
let acis = vec![AciBuilder::new("deny-non-admins")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.permission(OperationType::Read)
.bind_rule(BindRule::Not(Box::new(BindRule::GroupDn(
"cn=admins,ou=groups,dc=example,dc=com".to_string(),
))))
.deny()
.build()];
let compiled = LdapCompiledEvaluator::build(&acis);
let entry = LdapEntry::new("uid=alice,ou=people,dc=example,dc=com").with_attribute(
"memberOf",
vec!["cn=admins,ou=groups,dc=example,dc=com".to_string()],
);
let op = LdapOperation::new(OperationType::Read, &entry.dn);
let user_dn = Some("uid=alice,ou=people,dc=example,dc=com");
let authorize_result = compiled.authorize(&op, &entry, user_dn, Some(&entry));
let check_result = compiled.check_access(&op, &entry, user_dn, Some(&entry));
assert_eq!(
authorize_result.authorized, check_result,
"check_access and authorize must agree on implicit grant from Not(GroupDn) deny"
);
}
#[test]
fn check_access_deny_overrides_implicit_grant() {
let acis = vec![
AciBuilder::new("deny-non-admins")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.permission(OperationType::Read)
.bind_rule(BindRule::Not(Box::new(BindRule::GroupDn(
"cn=admins,ou=groups,dc=example,dc=com".to_string(),
))))
.deny()
.build(),
AciBuilder::new("deny-everyone")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.deny()
.build(),
];
let compiled = LdapCompiledEvaluator::build(&acis);
let entry = LdapEntry::new("uid=alice,ou=people,dc=example,dc=com").with_attribute(
"memberOf",
vec!["cn=admins,ou=groups,dc=example,dc=com".to_string()],
);
let op = LdapOperation::new(OperationType::Read, &entry.dn);
let user_dn = Some("uid=alice,ou=people,dc=example,dc=com");
let authorize_result = compiled.authorize(&op, &entry, user_dn, Some(&entry));
let check_result = compiled.check_access(&op, &entry, user_dn, Some(&entry));
assert!(
!check_result,
"explicit deny should override implicit grant"
);
assert_eq!(authorize_result.authorized, check_result);
}
#[test]
fn universal_allow_shortcut() {
let acis = vec![AciBuilder::new("allow-all")
.target_scope(Scope::Subtree)
.target_filter(TargetFilter::All)
.permission(OperationType::All)
.bind_rule(BindRule::Anyone)
.build()];
let compiled = LdapCompiledEvaluator::build(&acis);
assert!(compiled.per_op_universal_grant.iter().all(|&v| v));
let entry = LdapEntry::new("cn=anything");
let op = LdapOperation::new(OperationType::Search, &entry.dn);
assert!(compiled.check_access(&op, &entry, None, None));
}
#[test]
fn per_op_universal_grant_read_only() {
let acis = vec![AciBuilder::new("universal-read")
.target_scope(Scope::Subtree)
.target_filter(TargetFilter::All)
.permission(OperationType::Read)
.permission(OperationType::Search)
.permission(OperationType::Compare)
.bind_rule(BindRule::Anyone)
.build()];
let compiled = LdapCompiledEvaluator::build(&acis);
assert!(compiled.per_op_universal_grant[OperationType::Read.bit_index().unwrap()]);
assert!(compiled.per_op_universal_grant[OperationType::Search.bit_index().unwrap()]);
assert!(compiled.per_op_universal_grant[OperationType::Compare.bit_index().unwrap()]);
assert!(!compiled.per_op_universal_grant[OperationType::Modify.bit_index().unwrap()]);
assert!(!compiled.per_op_universal_grant[OperationType::Add.bit_index().unwrap()]);
let entry = LdapEntry::new("cn=anything");
let read_op = LdapOperation::new(OperationType::Read, &entry.dn);
assert!(compiled.check_access(&read_op, &entry, None, None));
let modify_op = LdapOperation::new(OperationType::Modify, &entry.dn);
assert!(!compiled.check_access(&modify_op, &entry, None, None));
}
#[test]
fn per_op_universal_grant_with_deny() {
let acis = vec![
AciBuilder::new("universal-read")
.target_scope(Scope::Subtree)
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.build(),
AciBuilder::new("deny-password")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.target_attribute("userpassword")
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.deny()
.build(),
];
let compiled = LdapCompiledEvaluator::build(&acis);
assert!(compiled.per_op_universal_grant[OperationType::Read.bit_index().unwrap()]);
assert!(compiled.has_deny);
let entry = LdapEntry::new("uid=alice,ou=people,dc=example,dc=com");
let op_cn = LdapOperation::new(OperationType::Read, &entry.dn)
.with_attributes(vec!["cn".to_string()]);
assert!(compiled.check_access(&op_cn, &entry, None, None));
let op_pw = LdapOperation::new(OperationType::Read, &entry.dn)
.with_attributes(vec!["userpassword".to_string()]);
assert!(!compiled.check_access(&op_pw, &entry, None, None));
let other_entry = LdapEntry::new("cn=config");
let op_other = LdapOperation::new(OperationType::Read, &other_entry.dn)
.with_attributes(vec!["userpassword".to_string()]);
assert!(compiled.check_access(&op_other, &other_entry, None, None));
}
}