use crate::aci::{Aci, BindRule, Scope, TargetAttrFilter, TargetAttrFilterOp, TargetFilter};
use crate::operation::{OperationType, PermissionSlice};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum GenerateError {
UnsupportedFeature(String),
InvalidAci(String),
}
impl fmt::Display for GenerateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GenerateError::UnsupportedFeature(msg) => write!(f, "Unsupported feature: {}", msg),
GenerateError::InvalidAci(msg) => write!(f, "Invalid ACI: {}", msg),
}
}
}
impl std::error::Error for GenerateError {}
pub trait AciGenerator {
fn generate(acis: &[Aci]) -> Result<String, GenerateError>;
fn name() -> &'static str;
}
pub struct Ds389;
impl AciGenerator for Ds389 {
fn generate(acis: &[Aci]) -> Result<String, GenerateError> {
if acis.is_empty() {
return Ok(String::new());
}
acis.iter()
.map(generate_389ds_aci_impl)
.collect::<Result<Vec<_>, _>>()
.map(|lines| lines.join("\n"))
}
fn name() -> &'static str {
"389-ds"
}
}
pub struct OpenLdap;
impl AciGenerator for OpenLdap {
fn generate(acis: &[Aci]) -> Result<String, GenerateError> {
generate_openldap_access_impl(acis)
}
fn name() -> &'static str {
"OpenLDAP"
}
}
pub fn generate<F: AciGenerator>(acis: &[Aci]) -> Result<String, GenerateError> {
F::generate(acis)
}
fn generate_389ds_aci_impl(aci: &Aci) -> Result<String, GenerateError> {
let mut parts = Vec::new();
if let Some(ref dn) = aci.target_dn {
parts.push(format!("(target = \"ldap:///{}\")", dn));
}
if !aci.target_attributes.is_empty() {
let attrs = aci.target_attributes.join(" || ");
let op = if aci.target_attributes_excluded {
"!="
} else {
"="
};
parts.push(format!("(targetattr {} \"{}\")", op, attrs));
}
if !aci.target_attr_filters.is_empty() {
parts.push(format!(
"(targattrfilters = \"{}\")",
format_targattrfilters(&aci.target_attr_filters)
));
}
if let Some(ref from_dn) = aci.target_from {
parts.push(format!("(target_from = \"ldap:///{}\")", from_dn));
}
if let Some(ref to_dn) = aci.target_to {
parts.push(format!("(target_to = \"ldap:///{}\")", to_dn));
}
match &aci.target_filter {
TargetFilter::All => {
}
TargetFilter::DnPattern(_) => {
}
filter => {
let filter_str = format_target_filter(filter)?;
parts.push(format!("(targetfilter = \"({})\")", filter_str));
}
}
let grant_deny = if aci.grant { "allow" } else { "deny" };
let perms = format_permissions_389ds(&aci.permissions)?;
let bind_rule = format_bind_rule_389ds(&aci.bind_rule)?;
parts.push(format!(
"(version 3.0;acl \"{}\";{} ({}) {};)",
aci.name, grant_deny, perms, bind_rule
));
Ok(parts.join(""))
}
fn format_targattrfilters(filters: &[TargetAttrFilter]) -> String {
let mut add_parts = Vec::new();
let mut del_parts = Vec::new();
for f in filters {
let entry = format!("{}:({})", f.attr, f.filter);
match f.op {
TargetAttrFilterOp::Add => add_parts.push(entry),
TargetAttrFilterOp::Del => del_parts.push(entry),
}
}
let mut sections = Vec::new();
if !add_parts.is_empty() {
sections.push(format!("add={}", add_parts.join(" && ")));
}
if !del_parts.is_empty() {
sections.push(format!("del={}", del_parts.join(" && ")));
}
sections.join(", ")
}
fn format_target_filter(filter: &TargetFilter) -> Result<String, GenerateError> {
match filter {
TargetFilter::All => Ok("objectclass=*".to_string()),
TargetFilter::DnPattern(_) => Err(GenerateError::UnsupportedFeature(
"DN patterns should be in target, not targetfilter".to_string(),
)),
TargetFilter::ObjectClass(oc) => Ok(format!("objectclass={}", oc)),
TargetFilter::HasAttribute(attr) => Ok(format!("{}=*", attr)),
TargetFilter::And(filters) => {
let inner = filters
.iter()
.map(|f| format_target_filter(f).map(|s| format!("({})", s)))
.collect::<Result<Vec<_>, _>>()?
.join("");
Ok(format!("&{}", inner))
}
TargetFilter::Or(filters) => {
let inner = filters
.iter()
.map(|f| format_target_filter(f).map(|s| format!("({})", s)))
.collect::<Result<Vec<_>, _>>()?
.join("");
Ok(format!("|{}", inner))
}
TargetFilter::Not(inner) => {
let inner_str = format_target_filter(inner)?;
Ok(format!(
"!{}",
if inner_str.starts_with('(') {
inner_str
} else {
format!("({})", inner_str)
}
))
}
TargetFilter::Raw(s) => Ok(s.clone()),
}
}
fn format_permissions_389ds(perms: &[OperationType]) -> Result<String, GenerateError> {
if perms.is_empty() {
return Err(GenerateError::InvalidAci(
"ACI must have at least one permission".to_string(),
));
}
let perm_strs: Vec<_> = perms
.iter()
.map(|p| match p {
OperationType::Read => "read",
OperationType::Search => "search",
OperationType::Compare => "compare",
OperationType::Modify => "write",
OperationType::Add => "add",
OperationType::Delete => "delete",
OperationType::ModifyDn => "moddn",
OperationType::Bind => "proxy",
OperationType::All => "all",
OperationType::SelfWrite => "selfwrite",
})
.collect();
Ok(perm_strs.join(","))
}
fn format_bind_rule_389ds(bind_rule: &BindRule) -> Result<String, GenerateError> {
match bind_rule {
BindRule::Anyone => Ok("userdn = \"ldap:///anyone\"".to_string()),
BindRule::Authenticated => Ok("userdn = \"ldap:///all\"".to_string()),
BindRule::SelfUser => Ok("userdn = \"ldap:///self\"".to_string()),
BindRule::UserDn(dn) => Ok(format!("userdn = \"ldap:///{}\"", dn)),
BindRule::GroupDn(dn) => Ok(format!("groupdn = \"ldap:///{}\"", dn)),
BindRule::RoleAttribute(attr, value) => Ok(format!("userattr = \"{}#{}\"", attr, value)),
BindRule::ParentDn => Ok("userdn = \"ldap:///parent\"".to_string()),
BindRule::RoleDn(dn) => Ok(format!("roledn = \"ldap:///{}\"", dn)),
BindRule::UserDnAttr(attr) => Ok(format!("userdnattr = \"{}\"", attr)),
BindRule::GroupDnAttr(attr) => Ok(format!("groupdnattr = \"{}\"", attr)),
BindRule::Ip(val) => Ok(format!("ip = \"{}\"", val)),
BindRule::Dns(val) => Ok(format!("dns = \"{}\"", val)),
BindRule::AuthMethod(val) => Ok(format!("authmethod = \"{}\"", val)),
BindRule::DayOfWeek(val) => Ok(format!("dayofweek = \"{}\"", val)),
BindRule::TimeOfDay(op, val) => Ok(format!("timeofday {} \"{}\"", op, val)),
BindRule::Ssf(op, val) => Ok(format!("ssf {} \"{}\"", op, val)),
BindRule::Or(rules) => {
let parts: Vec<String> = rules
.iter()
.map(format_bind_rule_389ds)
.collect::<Result<_, _>>()?;
Ok(parts.join(" or "))
}
BindRule::And(rules) => {
let parts: Vec<String> = rules
.iter()
.map(format_bind_rule_389ds)
.collect::<Result<_, _>>()?;
Ok(parts.join(" and "))
}
BindRule::Not(inner) => {
let inner_str = match inner.as_ref() {
BindRule::UserDn(dn) => format!("userdn != \"ldap:///{}\"", dn),
BindRule::Anyone => "userdn != \"ldap:///anyone\"".to_string(),
BindRule::Authenticated => "userdn != \"ldap:///all\"".to_string(),
BindRule::SelfUser => "userdn != \"ldap:///self\"".to_string(),
BindRule::GroupDn(dn) => format!("groupdn != \"ldap:///{}\"", dn),
BindRule::RoleDn(dn) => format!("roledn != \"ldap:///{}\"", dn),
BindRule::Ip(val) => format!("ip != \"{}\"", val),
BindRule::Dns(val) => format!("dns != \"{}\"", val),
_ => {
return Err(GenerateError::UnsupportedFeature(
"Nested NOT in 389-ds bind rule".to_string(),
))
}
};
Ok(inner_str)
}
}
}
fn generate_openldap_access_impl(acis: &[Aci]) -> Result<String, GenerateError> {
if acis.is_empty() {
return Ok(String::new());
}
let mut target_groups: Vec<(String, Vec<&Aci>)> = Vec::new();
for aci in acis {
let target_key = format_openldap_target(aci)?;
if let Some((_, acis)) = target_groups.iter_mut().find(|(k, _)| k == &target_key) {
acis.push(aci);
} else {
target_groups.push((target_key, vec![aci]));
}
}
let mut rules = Vec::new();
for (target_spec, acis) in target_groups {
let mut lines = vec![target_spec];
for aci in acis {
let who = format_who_clause_openldap(&aci.bind_rule)?;
let access = format_access_level_openldap(&aci.permissions, aci.grant)?;
lines.push(format!(" by {} {}", who, access));
}
rules.push(lines.join("\n"));
}
Ok(rules.join("\n\n"))
}
fn format_openldap_target(aci: &Aci) -> Result<String, GenerateError> {
let mut parts = Vec::new();
if let Some(ref dn) = aci.target_dn {
let scope_str = match aci.scope {
Scope::Base => "base",
Scope::OneLevel => "one",
Scope::Subtree => "subtree",
};
parts.push(format!("dn.{}=\"{}\"", scope_str, dn));
} else {
parts.push("*".to_string());
}
if !aci.target_attributes.is_empty() {
let attrs = aci.target_attributes.join(",");
parts.push(format!("attrs={}", attrs));
}
match &aci.target_filter {
TargetFilter::All => {
}
TargetFilter::DnPattern(_) => {
}
filter => {
let filter_str = format_target_filter(filter)?;
parts.push(format!("filter=\"({})\"", filter_str));
}
}
Ok(format!("to {}", parts.join(" ")))
}
fn format_who_clause_openldap(bind_rule: &BindRule) -> Result<String, GenerateError> {
match bind_rule {
BindRule::Anyone => Ok("*".to_string()),
BindRule::Authenticated => Ok("users".to_string()),
BindRule::SelfUser => Ok("self".to_string()),
BindRule::UserDn(dn) => Ok(format!("dn.exact=\"{}\"", dn)),
BindRule::GroupDn(dn) => Ok(format!("group.exact=\"{}\"", dn)),
BindRule::RoleAttribute(attr, value) => Err(GenerateError::UnsupportedFeature(format!(
"Role attribute {}={} not directly supported in OpenLDAP",
attr, value
))),
BindRule::And(_) | BindRule::Or(_) | BindRule::Not(_) => {
Err(GenerateError::UnsupportedFeature(
"Complex boolean bind rules not yet supported in OpenLDAP generator".to_string(),
))
}
BindRule::ParentDn
| BindRule::RoleDn(_)
| BindRule::UserDnAttr(_)
| BindRule::GroupDnAttr(_)
| BindRule::Ip(_)
| BindRule::Dns(_)
| BindRule::AuthMethod(_)
| BindRule::DayOfWeek(_)
| BindRule::TimeOfDay(_, _)
| BindRule::Ssf(_, _) => Err(GenerateError::UnsupportedFeature(
"389-ds specific bind rule not supported in OpenLDAP generator".to_string(),
)),
}
}
fn format_access_level_openldap(
perms: &[OperationType],
grant: bool,
) -> Result<String, GenerateError> {
if !grant {
return Ok("none".to_string());
}
if perms.is_empty() {
return Ok("none".to_string());
}
let has_all = perms.contains(&OperationType::All);
if has_all || perms.grants_write() {
Ok("write".to_string())
} else if perms.grants(&OperationType::Read) {
Ok("read".to_string())
} else if perms.grants(&OperationType::Search) {
Ok("search".to_string())
} else if perms.grants(&OperationType::Compare) {
Ok("compare".to_string())
} else {
Ok("none".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aci::AciBuilder;
#[test]
fn test_generate_389ds_simple() {
let aci = AciBuilder::new("test read")
.target_attribute("cn")
.target_attribute("mail")
.permission(OperationType::Read)
.permission(OperationType::Search)
.bind_rule(BindRule::Anyone)
.build();
let result = generate::<Ds389>(&[aci]).unwrap();
assert!(result.contains("targetattr = \"cn || mail\""));
assert!(result.contains("version 3.0"));
assert!(result.contains("acl \"test read\""));
assert!(result.contains("allow (read,search)"));
assert!(result.contains("userdn = \"ldap:///anyone\""));
}
#[test]
fn test_generate_389ds_with_target() {
let aci = AciBuilder::new("self modify")
.target_dn("uid=*,ou=people,dc=example,dc=com")
.target_attribute("description")
.permission(OperationType::Modify)
.bind_rule(BindRule::SelfUser)
.build();
let result = generate::<Ds389>(&[aci]).unwrap();
assert!(result.contains("target = \"ldap:///uid=*,ou=people,dc=example,dc=com\""));
assert!(result.contains("targetattr = \"description\""));
assert!(result.contains("allow (write)"));
assert!(result.contains("userdn = \"ldap:///self\""));
}
#[test]
fn test_generate_389ds_deny() {
let aci = AciBuilder::new("deny password")
.target_attribute("userPassword")
.permission(OperationType::Read)
.permission(OperationType::Compare)
.bind_rule(BindRule::Authenticated)
.deny()
.build();
let result = generate::<Ds389>(&[aci]).unwrap();
assert!(result.contains("deny (read,compare)"));
assert!(result.contains("userdn = \"ldap:///all\""));
}
#[test]
fn test_generate_openldap_simple() {
let aci1 = AciBuilder::new("self write")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.target_attribute("cn")
.target_attribute("mail")
.permission(OperationType::Modify)
.bind_rule(BindRule::SelfUser)
.build();
let aci2 = AciBuilder::new("public read")
.target_dn("ou=people,dc=example,dc=com")
.target_scope(Scope::Subtree)
.target_attribute("cn")
.target_attribute("mail")
.permission(OperationType::Read)
.bind_rule(BindRule::Anyone)
.build();
let result = generate::<OpenLdap>(&[aci1, aci2]).unwrap();
assert!(result.contains("to dn.subtree=\"ou=people,dc=example,dc=com\" attrs=cn,mail"));
assert!(result.contains("by self write"));
assert!(result.contains("by * read"));
}
#[test]
fn test_generate_openldap_with_group() {
let aci = AciBuilder::new("admin write")
.target_dn("dc=example,dc=com")
.target_scope(Scope::Base)
.permission(OperationType::Modify)
.bind_rule(BindRule::GroupDn(
"cn=admins,ou=groups,dc=example,dc=com".to_string(),
))
.build();
let result = generate::<OpenLdap>(&[aci]).unwrap();
assert!(result.contains("to dn.base=\"dc=example,dc=com\""));
assert!(result.contains("by group.exact=\"cn=admins,ou=groups,dc=example,dc=com\" write"));
}
#[test]
fn test_round_trip_389ds() {
use crate::parser::{parse, Ds389 as Ds389Parser};
let original = r#"(targetattr = "cn || mail")(version 3.0;acl "test";allow (read,search) userdn = "ldap:///anyone";)"#;
let parsed = parse::<Ds389Parser>(original).unwrap();
let generated = generate::<Ds389>(&parsed).unwrap();
let re_parsed = parse::<Ds389Parser>(&generated).unwrap();
assert_eq!(parsed.len(), re_parsed.len());
assert_eq!(parsed[0].name, re_parsed[0].name);
assert_eq!(parsed[0].target_attributes, re_parsed[0].target_attributes);
assert_eq!(parsed[0].permissions, re_parsed[0].permissions);
}
#[test]
fn test_round_trip_389ds_compound_or() {
use crate::parser::{parse, Ds389 as Ds389Parser};
let original = r#"(targetattr = "member")(version 3.0;acl "compound or";allow (write) userattr = "memberManager#USERDN" or userattr = "memberManager#GROUPDN";)"#;
let parsed = parse::<Ds389Parser>(original).unwrap();
assert!(matches!(&parsed[0].bind_rule, BindRule::Or(_)));
let generated = generate::<Ds389>(&parsed).unwrap();
let re_parsed = parse::<Ds389Parser>(&generated).unwrap();
assert_eq!(parsed[0].name, re_parsed[0].name);
assert_eq!(parsed[0].bind_rule, re_parsed[0].bind_rule);
}
#[test]
fn test_generate_389ds_not() {
let aci = AciBuilder::new("deny anon")
.target_attribute("cn")
.permission(OperationType::Read)
.bind_rule(BindRule::Not(Box::new(BindRule::Anyone)))
.build();
let result = generate::<Ds389>(&[aci]).unwrap();
assert!(result.contains(r#"userdn != "ldap:///anyone""#));
}
}