use crate::aci::{
Aci, AciBuilder, BindRule, CompOp, Scope, TargetAttrFilter, TargetAttrFilterOp, TargetFilter,
};
use crate::operation::OperationType;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
InvalidSyntax(String),
MissingField(String),
UnknownOperation(String),
UnknownBindRule(String),
MalformedTarget(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidSyntax(s) => write!(f, "Invalid ACI syntax: {}", s),
Self::MissingField(s) => write!(f, "Missing required field: {}", s),
Self::UnknownOperation(s) => write!(f, "Unknown operation: {}", s),
Self::UnknownBindRule(s) => write!(f, "Unknown bind rule: {}", s),
Self::MalformedTarget(s) => write!(f, "Malformed target: {}", s),
}
}
}
impl std::error::Error for ParseError {}
pub trait AciFormat {
fn parse(input: &str) -> Result<Vec<Aci>, ParseError>;
fn name() -> &'static str;
}
#[derive(Debug, Clone, Copy)]
pub struct Ds389;
impl AciFormat for Ds389 {
fn parse(input: &str) -> Result<Vec<Aci>, ParseError> {
let mut acis = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue; }
acis.push(parse_389ds_aci_impl(line)?);
}
if acis.is_empty() {
let input = input.trim();
if !input.is_empty() {
acis.push(parse_389ds_aci_impl(input)?);
}
}
Ok(acis)
}
fn name() -> &'static str {
"389-ds"
}
}
#[derive(Debug, Clone, Copy)]
pub struct OpenLdap;
impl AciFormat for OpenLdap {
fn parse(input: &str) -> Result<Vec<Aci>, ParseError> {
parse_openldap_access_impl(input)
}
fn name() -> &'static str {
"OpenLDAP"
}
}
pub fn parse<F: AciFormat>(input: &str) -> Result<Vec<Aci>, ParseError> {
F::parse(input)
}
fn parse_389ds_aci_impl(input: &str) -> Result<Aci, ParseError> {
let input = input.trim();
let mut target_attrs = Vec::new();
let mut target_attrs_excluded = false;
let mut target_dn = None;
let mut target_filter = TargetFilter::All;
let mut scope = Scope::Base;
let mut grant = true;
let mut permissions = Vec::new();
let mut bind_rule = BindRule::Authenticated;
if let Some(attrs_str) = extract_keyword_value(input, "targetattrs", "!=", "")
.or_else(|| extract_keyword_value(input, "targetattr", "!=", ""))
{
target_attrs_excluded = true;
target_attrs = attrs_str
.split("||")
.map(|s| s.trim().to_string())
.collect();
} else if let Some(attrs_str) = extract_keyword_value(input, "targetattrs", "=", "")
.or_else(|| extract_keyword_value(input, "targetattr", "=", ""))
{
target_attrs = attrs_str
.split("||")
.map(|s| s.trim().to_string())
.collect();
}
if let Some(target_str) = extract_keyword_value(input, "target", "=", "ldap:///") {
target_dn = Some(target_str.to_string());
if target_str.contains("*") {
scope = Scope::OneLevel;
}
}
if let Some(filter_str) = extract_keyword_value(input, "targetfilter", "=", "") {
target_filter = parse_ldap_filter(&filter_str)?;
}
let target_attr_filters =
if let Some(taf_str) = extract_keyword_value(input, "targattrfilters", "=", "") {
parse_targattrfilters(&taf_str)?
} else {
Vec::new()
};
let target_from = extract_keyword_value(input, "target_from", "=", "ldap:///");
let target_to = extract_keyword_value(input, "target_to", "=", "ldap:///");
let name = if let Some(name_str) = extract_between(input, "acl \"", "\"") {
name_str.to_string()
} else {
return Err(ParseError::MissingField("acl name".to_string()));
};
if let Some((is_grant, perms_str)) = extract_grant_permissions(input) {
grant = is_grant;
for perm in perms_str.split(',') {
permissions.push(parse_operation_type(perm.trim())?);
}
}
if let Some(bind_text) = extract_bind_rule_text(input) {
bind_rule = parse_compound_bind_rule(bind_text)?;
}
let mut builder = AciBuilder::new(name).bind_rule(bind_rule);
if let Some(dn) = target_dn {
builder = builder.target_dn(dn).target_scope(scope);
}
builder = builder.target_filter(target_filter);
for attr in target_attrs {
builder = builder.target_attribute(attr);
}
builder = builder
.target_attributes_excluded(target_attrs_excluded)
.target_attr_filters(target_attr_filters);
if let Some(tf) = target_from {
builder = builder.target_from(tf);
}
if let Some(tt) = target_to {
builder = builder.target_to(tt);
}
for perm in permissions {
builder = builder.permission(perm);
}
if !grant {
builder = builder.deny();
}
Ok(builder.build())
}
fn parse_openldap_access_impl(input: &str) -> Result<Vec<Aci>, ParseError> {
let input = input.trim();
let mut acis = Vec::new();
let (to_clause, by_clauses) = if let Some(idx) = input.find("by ") {
(&input[..idx], &input[idx..])
} else {
return Err(ParseError::InvalidSyntax("Missing 'by' clause".to_string()));
};
let (target_dn, scope) = parse_openldap_target(to_clause)?;
let target_attrs = if let Some(attrs_str) = extract_between(to_clause, "attrs=", " ") {
attrs_str.split(',').map(|s| s.trim().to_string()).collect()
} else if let Some(attrs_str) = extract_after(to_clause, "attrs=") {
attrs_str.split(',').map(|s| s.trim().to_string()).collect()
} else {
Vec::new()
};
let target_filter = if let Some(filter_str) = extract_between(to_clause, "filter=\"", "\"") {
parse_ldap_filter(&filter_str)?
} else {
TargetFilter::All
};
let by_parts: Vec<&str> = by_clauses.split("by ").filter(|s| !s.is_empty()).collect();
for (idx, by_clause) in by_parts.iter().enumerate() {
let parts: Vec<&str> = by_clause.split_whitespace().collect();
if parts.len() < 2 {
continue;
}
let who = parts[0];
let access = parts[1];
let bind_rule = parse_openldap_who(who)?;
let permissions = parse_openldap_access_level(access)?;
let mut builder = AciBuilder::new(format!("openldap-rule-{}", idx))
.bind_rule(bind_rule)
.target_scope(scope);
if let Some(dn) = &target_dn {
builder = builder.target_dn(dn);
}
builder = builder.target_filter(target_filter.clone());
for attr in &target_attrs {
builder = builder.target_attribute(attr);
}
for perm in permissions {
builder = builder.permission(perm);
}
acis.push(builder.build());
}
Ok(acis)
}
fn extract_between(input: &str, start: &str, end: &str) -> Option<String> {
let start_idx = input.find(start)? + start.len();
let remaining = &input[start_idx..];
let end_idx = remaining.find(end)?;
Some(remaining[..end_idx].to_string())
}
fn extract_keyword_value(
input: &str,
keyword: &str,
op: &str,
value_prefix: &str,
) -> Option<String> {
let bytes = input.as_bytes();
let mut search_from = 0;
while let Some(rel_pos) = input[search_from..].find(keyword) {
let kw_pos = search_from + rel_pos;
let after_kw = kw_pos + keyword.len();
if let Some(&ch) = bytes.get(after_kw) {
if ch.is_ascii_alphanumeric() || ch == b'_' {
search_from = after_kw;
continue;
}
}
let pos = skip_ascii_whitespace(bytes, after_kw);
if !input[pos..].starts_with(op) {
search_from = after_kw;
continue;
}
let pos = pos + op.len();
let pos = skip_ascii_whitespace(bytes, pos);
if bytes.get(pos) != Some(&b'"') {
search_from = after_kw;
continue;
}
let value_start = pos + 1;
if !input[value_start..].starts_with(value_prefix) {
search_from = after_kw;
continue;
}
let content_start = value_start + value_prefix.len();
if let Some(end_pos) = input[content_start..].find('"') {
return Some(input[content_start..content_start + end_pos].to_string());
}
search_from = after_kw;
}
None
}
fn skip_ascii_whitespace(bytes: &[u8], from: usize) -> usize {
let mut pos = from;
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
pos += 1;
}
pos
}
fn extract_grant_permissions(input: &str) -> Option<(bool, String)> {
let bytes = input.as_bytes();
for (keyword, is_grant) in [("allow", true), ("deny", false)] {
let mut search_from = 0;
while let Some(rel_pos) = input[search_from..].find(keyword) {
let kw_pos = search_from + rel_pos;
let after_kw = kw_pos + keyword.len();
if kw_pos > 0
&& (bytes[kw_pos - 1].is_ascii_alphanumeric() || bytes[kw_pos - 1] == b'_')
{
search_from = after_kw;
continue;
}
if let Some(&ch) = bytes.get(after_kw) {
if ch.is_ascii_alphanumeric() || ch == b'_' {
search_from = after_kw;
continue;
}
}
let pos = skip_ascii_whitespace(bytes, after_kw);
if bytes.get(pos) != Some(&b'(') {
search_from = after_kw;
continue;
}
if let Some(end) = input[pos + 1..].find(')') {
return Some((is_grant, input[pos + 1..pos + 1 + end].to_string()));
}
search_from = after_kw;
}
}
None
}
fn extract_after(input: &str, pattern: &str) -> Option<String> {
let idx = input.find(pattern)? + pattern.len();
Some(input[idx..].trim().to_string())
}
const MAX_FILTER_DEPTH: usize = 32;
fn split_filter_components(input: &str) -> Result<Vec<String>, ParseError> {
let input = input.trim();
let mut components = Vec::new();
let mut depth = 0usize;
let mut start = None;
for (i, ch) in input.char_indices() {
match ch {
'(' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
')' => {
if depth == 0 {
return Err(ParseError::InvalidSyntax(
"Unbalanced parentheses in filter".to_string(),
));
}
depth -= 1;
if depth == 0 {
if let Some(s) = start {
components.push(input[s..=i].to_string());
start = None;
}
}
}
_ => {}
}
}
if depth != 0 {
return Err(ParseError::InvalidSyntax(
"Unbalanced parentheses in filter".to_string(),
));
}
Ok(components)
}
fn parse_ldap_filter(filter: &str) -> Result<TargetFilter, ParseError> {
parse_ldap_filter_depth(filter, 0)
}
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn parse_ldap_filter_depth(
filter: &str,
depth: usize,
) -> Result<TargetFilter, ParseError> {
if depth > MAX_FILTER_DEPTH {
return Err(ParseError::InvalidSyntax(format!(
"LDAP filter exceeds maximum nesting depth of {}",
MAX_FILTER_DEPTH
)));
}
let filter = filter.trim();
let filter = if filter.starts_with('(') && filter.ends_with(')') {
&filter[1..filter.len() - 1]
} else {
filter
};
if filter.starts_with("objectclass=") || filter.starts_with("objectClass=") {
let class = filter.split('=').nth(1).unwrap_or("").trim();
return Ok(TargetFilter::ObjectClass(class.to_string()));
}
if let Some(rest) = filter.strip_prefix('!') {
let inner = rest.trim();
let inner_filter = parse_ldap_filter_depth(inner, depth + 1)?;
return Ok(TargetFilter::Not(Box::new(inner_filter)));
}
if let Some(rest) = filter.strip_prefix('&') {
let conjuncts = split_filter_components(rest)?;
if conjuncts.is_empty() {
return Ok(TargetFilter::All);
}
let parsed: Vec<TargetFilter> = conjuncts
.into_iter()
.map(|c| parse_ldap_filter_depth(&c, depth + 1))
.collect::<Result<Vec<_>, _>>()?;
if parsed.len() == 1 {
return Ok(parsed.into_iter().next().unwrap());
}
return Ok(TargetFilter::And(parsed));
}
if let Some(rest) = filter.strip_prefix('|') {
let disjuncts = split_filter_components(rest)?;
if disjuncts.is_empty() {
return Ok(TargetFilter::All);
}
let parsed: Vec<TargetFilter> = disjuncts
.into_iter()
.map(|c| parse_ldap_filter_depth(&c, depth + 1))
.collect::<Result<Vec<_>, _>>()?;
if parsed.len() == 1 {
return Ok(parsed.into_iter().next().unwrap());
}
return Ok(TargetFilter::Or(parsed));
}
if let Some(eq_idx) = filter.find('=') {
let attr = filter[..eq_idx].trim();
if filter.ends_with('*') || filter.contains("=*") {
return Ok(TargetFilter::HasAttribute(attr.to_string()));
}
}
if !filter.is_empty() {
return Ok(TargetFilter::Raw(filter.to_string()));
}
Ok(TargetFilter::All)
}
fn parse_operation_type(s: &str) -> Result<OperationType, ParseError> {
match s.to_lowercase().as_str() {
"read" => Ok(OperationType::Read),
"search" => Ok(OperationType::Search),
"write" | "modify" => Ok(OperationType::Modify),
"add" => Ok(OperationType::Add),
"delete" => Ok(OperationType::Delete),
"compare" => Ok(OperationType::Compare),
"moddn" | "modrdn" => Ok(OperationType::ModifyDn),
"all" => Ok(OperationType::All),
"selfwrite" => Ok(OperationType::SelfWrite),
"proxy" => Ok(OperationType::Bind),
_ => Err(ParseError::UnknownOperation(s.to_string())),
}
}
fn extract_bind_rule_text(input: &str) -> Option<&str> {
let bytes = input.as_bytes();
for keyword in ["allow", "deny"] {
let mut search_from = 0;
while let Some(rel_pos) = input[search_from..].find(keyword) {
let kw_pos = search_from + rel_pos;
let after_kw = kw_pos + keyword.len();
if kw_pos > 0
&& (bytes[kw_pos - 1].is_ascii_alphanumeric() || bytes[kw_pos - 1] == b'_')
{
search_from = after_kw;
continue;
}
if let Some(&ch) = bytes.get(after_kw) {
if ch.is_ascii_alphanumeric() || ch == b'_' {
search_from = after_kw;
continue;
}
}
let pos = skip_ascii_whitespace(bytes, after_kw);
if bytes.get(pos) != Some(&b'(') {
search_from = after_kw;
continue;
}
if let Some(close_offset) = input[pos + 1..].find(')') {
let after_close = pos + 1 + close_offset + 1;
let bind_start = skip_ascii_whitespace(bytes, after_close);
if let Some(end_offset) = input[bind_start..].rfind(";)") {
let text = input[bind_start..bind_start + end_offset].trim();
if !text.is_empty() {
return Some(text);
}
}
}
search_from = after_kw;
}
}
None
}
fn split_outside_quotes<'a>(text: &'a str, separator: &str) -> Vec<&'a str> {
let mut parts = Vec::new();
let mut in_quote = false;
let mut last_start = 0;
let sep_len = separator.len();
let mut i = 0;
while i < text.len() {
if text.as_bytes()[i] == b'"' {
in_quote = !in_quote;
i += 1;
} else if !in_quote
&& i + sep_len <= text.len()
&& text[i..i + sep_len].eq_ignore_ascii_case(separator)
{
parts.push(&text[last_start..i]);
last_start = i + sep_len;
i = last_start;
} else {
i += 1;
}
}
parts.push(&text[last_start..]);
parts
}
fn parse_compound_bind_rule(text: &str) -> Result<BindRule, ParseError> {
let or_parts = split_outside_quotes(text, " or ");
if or_parts.len() > 1 {
let mut rules = Vec::new();
for part in &or_parts {
rules.push(parse_and_bind_rules(part.trim())?);
}
return Ok(BindRule::Or(rules));
}
parse_and_bind_rules(text)
}
fn parse_and_bind_rules(text: &str) -> Result<BindRule, ParseError> {
let and_parts = split_outside_quotes(text, " and ");
if and_parts.len() > 1 {
let mut rules = Vec::new();
for part in &and_parts {
rules.push(parse_single_bind_rule(part.trim())?);
}
return Ok(BindRule::And(rules));
}
parse_single_bind_rule(text)
}
fn parse_single_bind_rule(clause: &str) -> Result<BindRule, ParseError> {
let clause = clause.trim();
if let Some(val) = extract_keyword_value(clause, "userdn", "!=", "ldap:///") {
Ok(BindRule::Not(Box::new(parse_userdn_rule(&val)?)))
} else if let Some(val) = extract_keyword_value(clause, "userdn", "=", "ldap:///") {
parse_multi_dn_value(&val, parse_userdn_rule)
} else if let Some(val) = extract_keyword_value(clause, "groupdn", "!=", "ldap:///") {
let inner = parse_multi_dn_value(&val, |dn| Ok(BindRule::GroupDn(dn.to_string())))?;
Ok(BindRule::Not(Box::new(inner)))
} else if let Some(val) = extract_keyword_value(clause, "groupdn", "!=", "") {
Ok(BindRule::Not(Box::new(BindRule::GroupDn(val))))
} else if let Some(val) = extract_keyword_value(clause, "groupdn", "=", "ldap:///") {
parse_multi_dn_value(&val, |dn| Ok(BindRule::GroupDn(dn.to_string())))
} else if let Some(val) = extract_keyword_value(clause, "groupdn", "=", "") {
Ok(BindRule::GroupDn(val))
} else if let Some(val) = extract_keyword_value(clause, "userattr", "=", "") {
parse_userattr_rule(&val)
} else if let Some(val) = extract_keyword_value(clause, "roledn", "!=", "ldap:///") {
let inner = parse_multi_dn_value(&val, |dn| Ok(BindRule::RoleDn(dn.to_string())))?;
Ok(BindRule::Not(Box::new(inner)))
} else if let Some(val) = extract_keyword_value(clause, "roledn", "=", "ldap:///") {
parse_multi_dn_value(&val, |dn| Ok(BindRule::RoleDn(dn.to_string())))
} else if let Some(val) = extract_keyword_value(clause, "userdnattr", "=", "") {
Ok(BindRule::UserDnAttr(val))
} else if let Some(val) = extract_keyword_value(clause, "groupdnattr", "=", "") {
Ok(BindRule::GroupDnAttr(val))
} else if let Some(val) = extract_keyword_value(clause, "ip", "!=", "") {
Ok(BindRule::Not(Box::new(BindRule::Ip(val))))
} else if let Some(val) = extract_keyword_value(clause, "ip", "=", "") {
Ok(BindRule::Ip(val))
} else if let Some(val) = extract_keyword_value(clause, "dns", "!=", "") {
Ok(BindRule::Not(Box::new(BindRule::Dns(val))))
} else if let Some(val) = extract_keyword_value(clause, "dns", "=", "") {
Ok(BindRule::Dns(val))
} else if let Some(val) = extract_keyword_value(clause, "authmethod", "=", "") {
Ok(BindRule::AuthMethod(val))
} else if let Some(val) = extract_keyword_value(clause, "dayofweek", "=", "") {
Ok(BindRule::DayOfWeek(val))
} else if clause.trim_start().starts_with("timeofday")
|| clause.trim_start().starts_with("TIMEOFDAY")
{
parse_bind_rule_with_comp_op(clause, "timeofday")
} else if clause.trim_start().starts_with("ssf") || clause.trim_start().starts_with("SSF") {
parse_bind_rule_with_comp_op(clause, "ssf")
} else {
Err(ParseError::UnknownBindRule(clause.to_string()))
}
}
fn parse_targattrfilters(s: &str) -> Result<Vec<TargetAttrFilter>, ParseError> {
let mut filters = Vec::new();
for section in split_respecting_parens(s, ',') {
let section = section.trim();
if section.is_empty() {
continue;
}
let (op, rest) = if let Some(rest) = section.strip_prefix("add=") {
(TargetAttrFilterOp::Add, rest)
} else if let Some(rest) = section.strip_prefix("del=") {
(TargetAttrFilterOp::Del, rest)
} else {
return Err(ParseError::InvalidSyntax(format!(
"targattrfilters section must start with add= or del=: {}",
section
)));
};
for entry in split_respecting_parens(rest, '&') {
let entry = entry.trim().trim_start_matches('&').trim();
if entry.is_empty() {
continue;
}
if let Some(colon_pos) = entry.find(":(") {
let attr = entry[..colon_pos].trim().to_string();
let filter_start = colon_pos + 1;
let filter = entry[filter_start..].trim();
let filter = if filter.starts_with('(') && filter.ends_with(')') {
filter[1..filter.len() - 1].to_string()
} else {
filter.to_string()
};
filters.push(TargetAttrFilter {
op: op.clone(),
attr,
filter,
});
}
}
}
Ok(filters)
}
fn split_respecting_parens(s: &str, sep: char) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0u32;
let mut last_start = 0;
for (i, ch) in s.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
c if c == sep && depth == 0 => {
parts.push(&s[last_start..i]);
last_start = i + 1;
}
_ => {}
}
}
parts.push(&s[last_start..]);
parts
}
fn parse_bind_rule_with_comp_op(clause: &str, keyword: &str) -> Result<BindRule, ParseError> {
let clause = clause.trim();
let lower = clause.to_lowercase();
if lower.starts_with(keyword) {
let offset = keyword.len();
let rest = &clause[offset..];
let (op, after_op) = parse_comp_op(rest)?;
let after_op = after_op.trim();
let value = if after_op.starts_with('"') && after_op.len() > 1 {
if let Some(end) = after_op[1..].find('"') {
&after_op[1..1 + end]
} else {
after_op.trim_matches('"')
}
} else {
after_op.trim_matches('"')
};
match keyword {
"ssf" => {
let n = value.parse::<u32>().map_err(|_| {
ParseError::InvalidSyntax(format!("ssf value must be integer: {}", value))
})?;
Ok(BindRule::Ssf(op, n))
}
"timeofday" => Ok(BindRule::TimeOfDay(op, value.to_string())),
_ => Err(ParseError::UnknownBindRule(clause.to_string())),
}
} else {
Err(ParseError::UnknownBindRule(clause.to_string()))
}
}
fn parse_comp_op(s: &str) -> Result<(CompOp, &str), ParseError> {
let s = s.trim_start();
if let Some(rest) = s.strip_prefix("!=") {
Ok((CompOp::NotEqual, rest))
} else if let Some(rest) = s.strip_prefix(">=") {
Ok((CompOp::GreaterEqual, rest))
} else if let Some(rest) = s.strip_prefix("<=") {
Ok((CompOp::LessEqual, rest))
} else if let Some(rest) = s.strip_prefix('>') {
Ok((CompOp::GreaterThan, rest))
} else if let Some(rest) = s.strip_prefix('<') {
Ok((CompOp::LessThan, rest))
} else if let Some(rest) = s.strip_prefix('=') {
Ok((CompOp::Equal, rest))
} else {
Err(ParseError::InvalidSyntax(format!(
"expected comparison operator, got: {}",
&s[..s.len().min(10)]
)))
}
}
fn parse_userattr_rule(s: &str) -> Result<BindRule, ParseError> {
if let Some(hash_pos) = s.rfind('#') {
let attr = s[..hash_pos].to_string();
let value = s[hash_pos + 1..].to_string();
Ok(BindRule::RoleAttribute(attr, value))
} else {
Err(ParseError::UnknownBindRule(format!("userattr: {}", s)))
}
}
fn parse_multi_dn_value<F>(val: &str, parse_one: F) -> Result<BindRule, ParseError>
where
F: Fn(&str) -> Result<BindRule, ParseError>,
{
if !val.contains(" || ") {
return parse_one(val);
}
let parts: Vec<&str> = val.split(" || ").collect();
let mut rules = Vec::with_capacity(parts.len());
for (i, part) in parts.iter().enumerate() {
let dn = if i > 0 {
part.trim().strip_prefix("ldap:///").unwrap_or(part.trim())
} else {
part.trim()
};
rules.push(parse_one(dn)?);
}
if rules.len() == 1 {
Ok(rules.into_iter().next().unwrap())
} else {
Ok(BindRule::Or(rules))
}
}
fn parse_userdn_rule(s: &str) -> Result<BindRule, ParseError> {
let lower = s.to_lowercase();
match lower.as_str() {
"anyone" => Ok(BindRule::Anyone),
"all" => Ok(BindRule::Authenticated),
"self" => Ok(BindRule::SelfUser),
"parent" => Ok(BindRule::ParentDn),
dn if dn.contains(',') => Ok(BindRule::UserDn(s.to_string())),
_ => Err(ParseError::UnknownBindRule(s.to_string())),
}
}
fn parse_openldap_target(to_clause: &str) -> Result<(Option<String>, Scope), ParseError> {
let to_clause = to_clause.trim();
if let Some(stripped) = to_clause.strip_prefix("to ") {
let remaining = stripped.trim();
if let Some(subtree_dn) = extract_between(remaining, "dn.subtree=\"", "\"") {
return Ok((Some(subtree_dn), Scope::Subtree));
}
if let Some(onelevel_dn) = extract_between(remaining, "dn.one=\"", "\"") {
return Ok((Some(onelevel_dn), Scope::OneLevel));
}
if let Some(children_dn) = extract_between(remaining, "dn.children=\"", "\"") {
return Ok((Some(children_dn), Scope::Subtree));
}
if let Some(base_dn) = extract_between(remaining, "dn.base=\"", "\"") {
return Ok((Some(base_dn), Scope::Base));
}
if let Some(exact_dn) = extract_between(remaining, "dn.exact=\"", "\"") {
return Ok((Some(exact_dn), Scope::Base));
}
if let Some(dn) = extract_between(remaining, "dn=\"", "\"") {
return Ok((Some(dn), Scope::Base));
}
}
Ok((None, Scope::Subtree))
}
fn parse_openldap_who(who: &str) -> Result<BindRule, ParseError> {
match who {
"*" | "anonymous" => Ok(BindRule::Anyone),
"users" => Ok(BindRule::Authenticated),
"self" => Ok(BindRule::SelfUser),
s if s.starts_with("dn.exact=\"") => {
if let Some(dn) = extract_between(s, "dn.exact=\"", "\"") {
Ok(BindRule::UserDn(dn))
} else {
Err(ParseError::UnknownBindRule(s.to_string()))
}
}
s if s.starts_with("group.exact=\"") || s.starts_with("group=\"") => {
let pattern = if s.starts_with("group.exact=\"") {
"group.exact=\""
} else {
"group=\""
};
if let Some(group) = extract_between(s, pattern, "\"") {
Ok(BindRule::GroupDn(group))
} else {
Err(ParseError::UnknownBindRule(s.to_string()))
}
}
_ => Err(ParseError::UnknownBindRule(who.to_string())),
}
}
fn parse_openldap_access_level(access: &str) -> Result<Vec<OperationType>, ParseError> {
match access.to_lowercase().as_str() {
"none" | "0" => Ok(vec![]),
"disclose" | "d" => Ok(vec![OperationType::Search]),
"auth" | "x" => Ok(vec![OperationType::Compare]),
"compare" | "c" => Ok(vec![OperationType::Compare, OperationType::Search]),
"search" | "s" => Ok(vec![OperationType::Search, OperationType::Compare]),
"read" | "r" => Ok(vec![
OperationType::Read,
OperationType::Search,
OperationType::Compare,
]),
"write" | "w" => Ok(vec![
OperationType::Modify,
OperationType::Add,
OperationType::Delete,
]),
"manage" | "m" => Ok(vec![
OperationType::Read,
OperationType::Search,
OperationType::Compare,
OperationType::Modify,
OperationType::Add,
OperationType::Delete,
OperationType::ModifyDn,
]),
_ => Err(ParseError::UnknownOperation(access.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_389ds_simple() {
let aci = r#"(targetattr = "cn || mail")(version 3.0;acl "read public";allow (read,search) userdn = "ldap:///anyone";)"#;
let results = parse::<Ds389>(aci).expect("test should succeed");
assert_eq!(results.len(), 1);
let result = &results[0];
assert_eq!(result.name, "read public");
assert_eq!(result.target_attributes.len(), 2);
assert!(result.target_attributes.contains(&"cn".to_string()));
assert!(result.grant);
assert_eq!(result.permissions.len(), 2);
}
#[test]
fn test_parse_389ds_with_target() {
let aci = r#"(target = "ldap:///cn=users,dc=example,dc=com")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "modify users";allow (write) groupdn = "ldap:///cn=admins,dc=example,dc=com";)"#;
let results = parse::<Ds389>(aci).expect("test should succeed");
let result = &results[0];
assert_eq!(result.name, "modify users");
assert!(result.target_dn.is_some());
assert_eq!(result.permissions.len(), 1);
assert!(matches!(result.bind_rule, BindRule::GroupDn(_)));
}
#[test]
fn test_parse_389ds_deny() {
let aci = r#"(targetattr = "userPassword")(version 3.0;acl "deny password";deny (read) userdn = "ldap:///all";)"#;
let results = parse::<Ds389>(aci).expect("test should succeed");
let result = &results[0];
assert!(!result.grant);
assert_eq!(result.name, "deny password");
}
#[test]
fn test_parse_389ds_targetattrs_plural() {
let aci_singular = r#"(targetattr = "cn || mail")(version 3.0;acl "test singular";allow (read) userdn = "ldap:///anyone";)"#;
let aci_plural = r#"(targetattrs = "cn || mail")(version 3.0;acl "test plural";allow (read) userdn = "ldap:///anyone";)"#;
let result_singular = parse::<Ds389>(aci_singular).expect("singular should parse");
let result_plural = parse::<Ds389>(aci_plural).expect("plural should parse");
assert_eq!(result_singular[0].target_attributes.len(), 2);
assert_eq!(result_plural[0].target_attributes.len(), 2);
assert_eq!(
result_singular[0].target_attributes,
result_plural[0].target_attributes
);
}
#[test]
fn test_parse_freeipa_token_aci() {
let aci = r#"(targetfilter = "(objectClass=ipaToken)")(targetattrs = "objectclass || description || managedBy || ipatokenUniqueID || ipatokenDisabled || ipatokenNotBefore || ipatokenNotAfter || ipatokenVendor || ipatokenModel || ipatokenSerial || ipatokenOwner")(version 3.0; acl "Users/managers can read basic token info"; allow (read, search, compare) userattr = "ipatokenOwner#USERDN" or userattr = "managedBy#USERDN";)"#;
let results = parse::<Ds389>(aci).expect("FreeIPA token ACI should parse");
let result = &results[0];
assert_eq!(result.name, "Users/managers can read basic token info");
assert_eq!(result.target_attributes.len(), 11);
assert!(result.grant);
match &result.bind_rule {
BindRule::Or(rules) => {
assert_eq!(rules.len(), 2);
assert_eq!(
rules[0],
BindRule::RoleAttribute("ipatokenOwner".into(), "USERDN".into())
);
assert_eq!(
rules[1],
BindRule::RoleAttribute("managedBy".into(), "USERDN".into())
);
}
_ => panic!("Expected Or bind rule, got {:?}", result.bind_rule),
}
}
#[test]
fn test_parse_compound_userdn_or() {
let aci = r#"(targetattr = "cn")(version 3.0;acl "test or";allow (read) userdn = "ldap:///anyone" or userdn = "ldap:///self";)"#;
let results = parse::<Ds389>(aci).unwrap();
match &results[0].bind_rule {
BindRule::Or(rules) => {
assert_eq!(rules.len(), 2);
assert_eq!(rules[0], BindRule::Anyone);
assert_eq!(rules[1], BindRule::SelfUser);
}
_ => panic!("Expected Or, got {:?}", results[0].bind_rule),
}
}
#[test]
fn test_parse_compound_groupdn_or() {
let aci = r#"(targetattr = "cn")(version 3.0;acl "test groups";allow (write) groupdn = "ldap:///cn=admins,dc=example,dc=com" or groupdn = "ldap:///cn=editors,dc=example,dc=com";)"#;
let results = parse::<Ds389>(aci).unwrap();
match &results[0].bind_rule {
BindRule::Or(rules) => {
assert_eq!(rules.len(), 2);
assert_eq!(
rules[0],
BindRule::GroupDn("cn=admins,dc=example,dc=com".into())
);
assert_eq!(
rules[1],
BindRule::GroupDn("cn=editors,dc=example,dc=com".into())
);
}
_ => panic!("Expected Or, got {:?}", results[0].bind_rule),
}
}
#[test]
fn test_parse_mixed_userdn_userattr_or() {
let aci = r#"(target="ldap:///krbprincipalname=cifs/($dn),cn=services,cn=accounts,dc=ipapq,dc=test")(targetattr="ipaNTHash")(version 3.0; acl "CIFS service can modify own ipaNTHash"; allow(write) userdn="ldap:///krbprincipalname=cifs/($dn),cn=services,cn=accounts,dc=ipapq,dc=test" or userattr="managedby#SELFDN";)"#;
let results = parse::<Ds389>(aci).unwrap();
match &results[0].bind_rule {
BindRule::Or(rules) => {
assert_eq!(rules.len(), 2);
assert!(matches!(&rules[0], BindRule::UserDn(_)));
assert_eq!(
rules[1],
BindRule::RoleAttribute("managedby".into(), "SELFDN".into())
);
}
_ => panic!("Expected Or, got {:?}", results[0].bind_rule),
}
}
#[test]
fn test_parse_member_manager_or() {
let aci = r#"(targetattr = "member")(version 3.0; acl "Allow member managers to modify members of user groups"; allow (write) userattr = "memberManager#USERDN" or userattr = "memberManager#GROUPDN";)"#;
let results = parse::<Ds389>(aci).unwrap();
match &results[0].bind_rule {
BindRule::Or(rules) => {
assert_eq!(rules.len(), 2);
assert_eq!(
rules[0],
BindRule::RoleAttribute("memberManager".into(), "USERDN".into())
);
assert_eq!(
rules[1],
BindRule::RoleAttribute("memberManager".into(), "GROUPDN".into())
);
}
_ => panic!("Expected Or, got {:?}", results[0].bind_rule),
}
}
#[test]
fn test_parse_userattr_semicolon_attribute() {
let aci = r#"(targetattr = "ipaAllowedToPerform;write_delegation")(version 3.0; acl "test subtype";allow (write) userattr="ipaAllowedToPerform;write_delegation#GROUPDN" or userattr="ipaAllowedToPerform;write_delegation#USERDN";)"#;
let results = parse::<Ds389>(aci).unwrap();
match &results[0].bind_rule {
BindRule::Or(rules) => {
assert_eq!(rules.len(), 2);
assert_eq!(
rules[0],
BindRule::RoleAttribute(
"ipaAllowedToPerform;write_delegation".into(),
"GROUPDN".into()
)
);
assert_eq!(
rules[1],
BindRule::RoleAttribute(
"ipaAllowedToPerform;write_delegation".into(),
"USERDN".into()
)
);
}
_ => panic!("Expected Or, got {:?}", results[0].bind_rule),
}
}
#[test]
fn test_parse_targattrfilters_simple() {
let aci = r#"(targetattr = "ipacertmapdata")(targattrfilters="add=objectclass:(objectclass=ipacertmapobject)")(version 3.0;acl "selfservice:Users can manage their own X.509 certificate identity mappings";allow (write) userdn = "ldap:///self";)"#;
let results = parse::<Ds389>(aci).unwrap();
let result = &results[0];
assert_eq!(result.target_attr_filters.len(), 1);
assert_eq!(result.target_attr_filters[0].op, TargetAttrFilterOp::Add);
assert_eq!(result.target_attr_filters[0].attr, "objectclass");
assert_eq!(
result.target_attr_filters[0].filter,
"objectclass=ipacertmapobject"
);
assert!(matches!(result.bind_rule, BindRule::SelfUser));
}
#[test]
fn test_parse_targattrfilters_add_and_del() {
let aci = r#"(targetfilter = "(objectclass=ipasubordinateidentry)")(targetattr="description || ipaowner || ipauniqueid")(targattrfilters = "add=objectClass:(|(objectClass=top)(objectClass=ipasubordinateid)), del=ipasubuidnumber:(!(ipasubuidnumber=*)) && ipasubgidnumber:(!(ipasubgidnumber=*))")(version 3.0;acl "test add+del";allow (add, write) userattr = "ipaowner#SELFDN";)"#;
let results = parse::<Ds389>(aci).unwrap();
let result = &results[0];
let add_filters: Vec<_> = result
.target_attr_filters
.iter()
.filter(|f| f.op == TargetAttrFilterOp::Add)
.collect();
let del_filters: Vec<_> = result
.target_attr_filters
.iter()
.filter(|f| f.op == TargetAttrFilterOp::Del)
.collect();
assert_eq!(add_filters.len(), 1);
assert_eq!(add_filters[0].attr, "objectClass");
assert_eq!(del_filters.len(), 2);
assert_eq!(del_filters[0].attr, "ipasubuidnumber");
assert_eq!(del_filters[1].attr, "ipasubgidnumber");
}
#[test]
fn test_targattrfilters_round_trip() {
use crate::generator::{generate, Ds389 as Ds389Gen};
let aci = r#"(targetattr = "ipacertmapdata")(targattrfilters="add=objectclass:(objectclass=ipacertmapobject)")(version 3.0;acl "test roundtrip";allow (write) userdn = "ldap:///self";)"#;
let parsed = parse::<Ds389>(aci).unwrap();
assert_eq!(parsed[0].target_attr_filters.len(), 1);
let generated = generate::<Ds389Gen>(&parsed).unwrap();
assert!(generated.contains("targattrfilters"));
let re_parsed = parse::<Ds389>(&generated).unwrap();
assert_eq!(
parsed[0].target_attr_filters,
re_parsed[0].target_attr_filters
);
}
#[test]
fn test_parse_openldap_simple() {
let rule = r#"to dn.subtree="ou=people,dc=example,dc=com" attrs=cn,mail
by self write
by * read"#;
let acis = parse::<OpenLdap>(rule).expect("test should succeed");
assert_eq!(acis.len(), 2);
assert_eq!(acis[0].target_attributes.len(), 2);
assert!(matches!(acis[0].bind_rule, BindRule::SelfUser));
assert!(matches!(acis[1].bind_rule, BindRule::Anyone));
}
#[test]
fn test_parse_openldap_with_group() {
let rule = r#"to dn.base="dc=example,dc=com"
by group.exact="cn=admins,ou=groups,dc=example,dc=com" write
by * read"#;
let acis = parse::<OpenLdap>(rule).expect("test should succeed");
assert!(!acis.is_empty());
if let BindRule::GroupDn(group) = &acis[0].bind_rule {
assert!(group.contains("admins"));
} else {
panic!("Expected GroupDn");
}
}
#[test]
fn test_userdn_preserves_kerberos_realm_case() {
let aci = r#"(target="ldap:///krbprincipalname=cifs/*@IPAPQ.TEST,cn=services,cn=accounts,dc=ipapq,dc=test")(targetattr="ipaNTHash")(version 3.0; acl "CIFS service can manage ID ranges for trust"; allow(read) userdn="ldap:///krbprincipalname=cifs/*@IPAPQ.TEST,cn=services,cn=accounts,dc=ipapq,dc=test";)"#;
let results = parse::<Ds389>(aci).unwrap();
match &results[0].bind_rule {
BindRule::UserDn(dn) => {
assert!(
dn.contains("@IPAPQ.TEST"),
"Kerberos realm case must be preserved, got: {}",
dn
);
}
other => panic!("Expected UserDn, got {:?}", other),
}
let generated = crate::generator::generate::<crate::generator::Ds389>(&results).unwrap();
assert!(
generated.contains("@IPAPQ.TEST"),
"Round-trip must preserve realm case, got: {}",
generated
);
}
#[test]
fn test_filter_recursion_protection() {
let filter = "(objectClass=person)";
let result = parse_ldap_filter_depth(filter, 0);
assert!(result.is_ok());
let result = parse_ldap_filter_depth(filter, MAX_FILTER_DEPTH + 1);
assert!(matches!(result, Err(ParseError::InvalidSyntax(_))));
if let Err(ParseError::InvalidSyntax(msg)) = result {
assert!(msg.contains("nesting depth"));
}
}
#[test]
fn test_parse_negation_filter() {
let filter = "(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=example,dc=com))";
let result = parse_ldap_filter(filter).unwrap();
assert!(
matches!(result, TargetFilter::Not(ref inner) if matches!(**inner, TargetFilter::Raw(_))),
"Expected Not(Raw(...)), got {:?}",
result
);
}
#[test]
fn test_parse_and_with_negation() {
let filter = "(&(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=example,dc=com))(objectclass=posixaccount))";
let result = parse_ldap_filter(filter).unwrap();
match result {
TargetFilter::And(ref filters) => {
assert_eq!(filters.len(), 2, "Expected 2 conjuncts, got {:?}", filters);
assert!(
matches!(&filters[0], TargetFilter::Not(_)),
"First conjunct should be Not, got {:?}",
filters[0]
);
assert!(
matches!(&filters[1], TargetFilter::ObjectClass(oc) if oc == "posixaccount"),
"Second conjunct should be ObjectClass, got {:?}",
filters[1]
);
}
_ => panic!("Expected And filter, got {:?}", result),
}
}
#[test]
fn test_parse_and_preserves_all_conjuncts() {
let filter = "(&(objectclass=posixaccount)(uid=*))";
let result = parse_ldap_filter(filter).unwrap();
match result {
TargetFilter::And(ref filters) => {
assert_eq!(filters.len(), 2);
assert!(
matches!(&filters[0], TargetFilter::ObjectClass(oc) if oc == "posixaccount")
);
assert!(matches!(&filters[1], TargetFilter::HasAttribute(a) if a == "uid"));
}
_ => panic!("Expected And filter, got {:?}", result),
}
}
#[test]
fn test_parse_or_filter() {
let filter = "(|(objectclass=posixaccount)(objectclass=inetorgperson))";
let result = parse_ldap_filter(filter).unwrap();
match result {
TargetFilter::Or(ref filters) => {
assert_eq!(filters.len(), 2);
}
_ => panic!("Expected Or filter, got {:?}", result),
}
}
#[test]
fn test_parse_aci_with_admin_exclusion_filter() {
let aci = r#"(targetattr = "userpassword")(targetfilter = "(&(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=example,dc=com))(objectclass=posixaccount))")(version 3.0;acl "change password";allow (write) groupdn = "ldap:///cn=user admins,cn=permissions,cn=pbac,dc=example,dc=com";)"#;
let results = parse::<Ds389>(aci).expect("should parse");
let result = &results[0];
assert_eq!(result.name, "change password");
match &result.target_filter {
TargetFilter::And(filters) => {
assert_eq!(filters.len(), 2);
assert!(matches!(&filters[0], TargetFilter::Not(_)));
assert!(matches!(&filters[1], TargetFilter::ObjectClass(_)));
}
other => panic!("Expected And filter with Not, got {:?}", other),
}
}
}