From 586faccc7c0866e48c83c57e3fd36b4b7082c17a Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Mon, 21 Sep 2026 15:10:43 +0200 Subject: [PATCH 1/2] feat: Support case-insensitive LDAP attributes in search --- src/ldap/attr.rs | 66 +++++++++++++++++++++++++++++++++++++++++++ src/ldap/mod.rs | 2 ++ src/ldap/op/search.rs | 44 +++++++++++++++++++---------- 3 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 src/ldap/attr.rs diff --git a/src/ldap/attr.rs b/src/ldap/attr.rs new file mode 100644 index 0000000..14bd72a --- /dev/null +++ b/src/ldap/attr.rs @@ -0,0 +1,66 @@ +use std::fmt; +use std::str::FromStr; + +#[derive(Clone, Debug, PartialEq)] +pub struct UnknownLdapAttribute(String); + +impl fmt::Display for UnknownLdapAttribute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Unknown LDAP attribute: {}", self.0) + } +} + +impl std::error::Error for UnknownLdapAttribute {} + +/// An LDAP attribute that is requested, or requested to be matched against an entry. +/// +/// Attributes are case-insensitive when parsing from a string. +/// +/// In the future, we may want to support custom attributes, but that is not +/// implemented for now. +#[derive(Clone, Debug, PartialEq)] +pub enum LdapAttribute { + Uid, + CommonName, + MemberOf, + ObjectClass, + Mail, + MailAlias, +} + +impl FromStr for LdapAttribute { + type Err = UnknownLdapAttribute; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "uid" => Ok(Self::Uid), + "cn" => Ok(Self::CommonName), + "memberof" => Ok(Self::MemberOf), + "objectclass" => Ok(Self::ObjectClass), + "mail" => Ok(Self::Mail), + "mailalias" => Ok(Self::MailAlias), + _ => Err(UnknownLdapAttribute(s.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uppercased_attribute() { + let s = "MemberOF"; + let attr = LdapAttribute::from_str(s); + println!("{attr:?}"); + assert_eq!(attr.unwrap(), LdapAttribute::MemberOf); + } + + #[test] + fn unknown_attribute() { + let s = "foobar"; + let attr = LdapAttribute::from_str(s); + println!("{attr:?}"); + assert_eq!(attr.unwrap_err(), UnknownLdapAttribute(s.to_string())); + } +} diff --git a/src/ldap/mod.rs b/src/ldap/mod.rs index 59da6d2..00f6174 100644 --- a/src/ldap/mod.rs +++ b/src/ldap/mod.rs @@ -1,3 +1,5 @@ +mod attr; +pub use attr::LdapAttribute; mod client_state; pub use client_state::LdapClientState; mod dn; diff --git a/src/ldap/op/search.rs b/src/ldap/op/search.rs index 5d77fcf..86ef63d 100644 --- a/src/ldap/op/search.rs +++ b/src/ldap/op/search.rs @@ -4,9 +4,11 @@ use ldap3_proto::proto::{ }; use ldap3_proto::{LdapMsg, LdapResultCode}; +use std::str::FromStr; + use crate::db::error::BoxedError; use crate::db::{Database, DatabaseInterface, User}; -use crate::ldap::{Dn, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; +use crate::ldap::{Dn, LdapAttribute, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; #[derive(Debug)] pub struct InvalidSearchDn { @@ -252,24 +254,36 @@ pub fn user_matches_filter(user: &User, filter: &LdapFilter) -> bool { false } LdapFilter::Not(sub_filter) => !user_matches_filter(user, sub_filter), - LdapFilter::Equality(attr, value) => match attr.as_ref() { - "uid" => user.username == *value, - // TODO: should CN be different than the mail? - "cn" | "mail" | "mailAlias" => user.mail == *value, - // TODO: group membership - "memberof" => false, - "objectClass" => matches!( - value.as_ref(), - "inetOrgPerson" | "posixAccount" | "mailAccount" | "person" - ), - _ => { - tracing::warn!("Unknown user attribute filter, considering no match: {attr}"); + LdapFilter::Equality(attr, value) => LdapAttribute::from_str(attr).map_or_else( + |e| { + tracing::warn!("Unrecognized attribute, considering no match: {e}"); false - } - }, + }, + |attr| user_matches_attribute(user, &attr, value), + ), _ => { tracing::warn!("Unimplemented search filter, considering no match: {filter:?}"); false } } } + +// TODO: we want to support group relations here as argument soon +pub fn user_matches_attribute(user: &User, attribute: &LdapAttribute, value: &str) -> bool { + match attribute { + // TODO: should we lowercase the value here? + LdapAttribute::Uid => user.username == *value, + // TODO: should CN be different than the mail? + // TODO: should we lowercase the value here? + LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => { + user.mail == *value + } + // TODO: group membership + LdapAttribute::MemberOf => false, + LdapAttribute::ObjectClass => matches!( + // We lowercase the value here because there's no ambiguity + value.to_lowercase().as_ref(), + "inetorgperson" | "posixaccount" | "mailaccount" | "person" + ), + } +} From e32b4bfb829cc3ba3cfd49af6333e9caaa018b0a Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Mon, 21 Sep 2026 15:39:49 +0200 Subject: [PATCH 2/2] refactor: Reuse typed LDAP attributes in search results --- src/ldap/op/search.rs | 58 ++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/ldap/op/search.rs b/src/ldap/op/search.rs index 86ef63d..0a29122 100644 --- a/src/ldap/op/search.rs +++ b/src/ldap/op/search.rs @@ -148,30 +148,42 @@ pub async fn search_success( fn search_entry_from_user(user: &User, req_attrs: &[String]) -> LdapSearchResultEntry { let mut res: Vec = vec![]; - for attr in req_attrs { - if let Some(attr_values) = match attr.as_str() { - "uid" => Some(vec![user.username.clone()]), - "cn" | "mail" => Some(vec![user.mail.clone()]), - // TODO: group membership - "memberof" => Some(vec![]), - // Copied from lldap output, not sure if we want to add/remove some classes depending on context - "objectclass" => Some( - vec!["inetOrgPerson", "posixAccount", "mailAccount", "person"] - .into_iter() - .map(String::from) - .collect(), - ), - _ => { - tracing::warn!("Ignoring unknown attr in search query: {attr}"); - None + for attr_str in req_attrs { + // We keep a copy of the requested attribute so that we can answer as it was requested, + // eg. `CN` => `CN` (instead of normalizing to `cn`). + let Some(attr) = LdapAttribute::from_str(attr_str) + .inspect_err(|e| tracing::debug!("Unknown attribute in request: {e}")) + .ok() + else { + continue; + }; + + let str_values = match attr { + // TODO: should we normalize the value some more here? + LdapAttribute::Uid => vec![user.username.clone()], + // TODO: should CN be different than the mail? + // TODO: should we normalize values some more here? + LdapAttribute::CommonName | LdapAttribute::Mail | LdapAttribute::MailAlias => { + vec![user.mail.clone()] } - } { - res.push(LdapPartialAttribute { - atype: attr.clone(), - // LDAP response expects raw byte vec for each value - vals: attr_values.into_iter().map(Vec::from).collect(), - }); - } + // TODO: group membership + LdapAttribute::MemberOf => vec![], + // TODO: if we introduce mail permission, we need to remove mailaccount from here + LdapAttribute::ObjectClass => vec![ + "inetOrgPerson".to_string(), + "posixAccount".to_string(), + "mailAccount".to_string(), + "person".to_string(), + ], + }; + + res.push(LdapPartialAttribute { + // Reuse the requested attribute name, not the normalized form + // we would otherwise produce. + atype: attr_str.clone(), + // LDAP response expects raw byte vec for each value + vals: str_values.into_iter().map(Vec::from).collect(), + }); } LdapSearchResultEntry {