use ldap3_proto::control::LdapControl; use ldap3_proto::proto::{ LdapFilter, LdapOp, LdapPartialAttribute, LdapResult, LdapSearchRequest, LdapSearchResultEntry, }; use ldap3_proto::{LdapMsg, LdapResultCode}; use std::str::FromStr; use crate::db::error::BoxedError; use crate::db::{Database, DatabaseInterface, User}; use crate::ldap::{Dn, LdapAttribute, LdapReturnError, LdapStream, LdapStreamError, MalformedDn}; #[derive(Debug)] pub struct InvalidSearchDn { dn: String, kind: InvalidSearchDnKind, } #[derive(Debug)] pub enum InvalidSearchDnKind { Malformed(MalformedDn), NoOu, MultipleOu, } impl LdapReturnError for InvalidSearchDn { fn code(&self) -> LdapResultCode { LdapResultCode::InvalidDNSyntax } fn message(&self) -> String { match &self.kind { InvalidSearchDnKind::Malformed(_e) => format!("Malformed dn: {}", self.dn), InvalidSearchDnKind::NoOu => format!("Missing `ou` in search dn: {}", self.dn), InvalidSearchDnKind::MultipleOu => { format!("Multiple `ou` tables provided in search dn: {}", self.dn) } } } } #[derive(Clone, Debug)] pub struct SearchDn(#[expect(unused)] Dn); impl SearchDn { pub fn from_dn_str(input: &str) -> Result { let dn = Dn::from_dn_str(input).map_err(|e| InvalidSearchDn { dn: input.to_string(), kind: InvalidSearchDnKind::Malformed(e), })?; let Some(ou) = dn.keys.get("ou") else { return Err(InvalidSearchDn { dn: input.to_string(), kind: InvalidSearchDnKind::NoOu, }); }; if ou.len() != 1 { return Err(InvalidSearchDn { dn: input.to_string(), kind: InvalidSearchDnKind::MultipleOu, }); } Ok(Self(dn)) } } #[derive(Debug)] pub enum SearchError { Db(BoxedError), InvalidDn(InvalidSearchDn), } impl SearchError { pub async fn error_message( &self, stream: &mut LdapStream, msgid: i32, ) -> Result<(), LdapStreamError> { let resp_msg = LdapMsg { msgid, op: LdapOp::SearchResultDone(LdapResult { code: self.code(), matcheddn: String::new(), message: self.message(), referral: vec![], }), ctrl: vec![], }; stream.send(resp_msg).await?; Ok(()) } } impl LdapReturnError for SearchError { fn code(&self) -> LdapResultCode { match self { Self::Db(_e) => LdapResultCode::Unavailable, Self::InvalidDn(e) => e.code(), } } fn message(&self) -> String { match self { Self::Db(e) => format!("Database error: {e}"), Self::InvalidDn(e) => e.message(), } } } pub async fn search_success( stream: &mut LdapStream, msgid: i32, entries: Vec<(LdapSearchResultEntry, Vec)>, ) -> Result<(), LdapStreamError> { let count = entries.len(); for (entry, ctrl) in entries { tracing::debug!("Search result: {entry:?}"); stream .send(LdapMsg { msgid, op: LdapOp::SearchResultEntry(entry), ctrl, }) .await?; } stream .send(LdapMsg { msgid, op: LdapOp::SearchResultDone(LdapResult { code: LdapResultCode::Success, matcheddn: String::new(), message: format!("Found {count} result(s)"), referral: vec![], }), // TODO: implement LdapControl for pagination ctrl: vec![], }) .await?; Ok(()) } 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 } } { res.push(LdapPartialAttribute { atype: attr.clone(), // LDAP response expects raw byte vec for each value vals: attr_values.into_iter().map(Vec::from).collect(), }); } } LdapSearchResultEntry { dn: Dn::from_user(user).to_dn_string(), attributes: res, } } pub async fn search_by_everything( stream: &mut LdapStream, db: &Database, sr: LdapSearchRequest, msgid: i32, ) -> Result<(), LdapStreamError> { // TODO: We should probably reuse the search DN somehow if let Err(e) = SearchDn::from_dn_str(&sr.base) { SearchError::InvalidDn(e) .error_message(stream, msgid) .await?; return Ok(()); } let users = match search_everything(db, &sr.filter).await { Ok(users) => users, Err(e) => { SearchError::Db(e).error_message(stream, msgid).await?; return Ok(()); } }; // TODO: pagination ctrl let entries = users .into_iter() .map(|user| (search_entry_from_user(&user, &sr.attrs), vec![])) .collect(); search_success(stream, msgid, entries).await?; Ok(()) } // TODO: pagination // TODO: support groups (objectclass=groupOfNames) and membership /// A very unoptimized search algorithm for the LDAP database. /// /// Key information: /// - ignores all filters that are not AND/OR/NOT/EQ (others may be added /// if we find a valid usecase) /// - iterates over every single entry in the database!!!!! pub async fn search_everything(db: &Database, sr: &LdapFilter) -> Result, BoxedError> { Ok(db .list_all_users() .await? .into_iter() .filter(|user| user_matches_filter(user, sr)) .collect()) } /// Checks if a user matches a single `LdapFilter`. /// /// Used recursively to see if a user matches a filter overall. pub fn user_matches_filter(user: &User, filter: &LdapFilter) -> bool { // TODO: we should also receive the group memberships as argument here // so we can match that in the future // For now, we only match mail/mailalias/uid attributes // TODO: implement mailalias, for now it's simply mapped to the mail attribute match filter { LdapFilter::And(filters) => { for sub_filter in filters { if !user_matches_filter(user, sub_filter) { return false; } } true } LdapFilter::Or(filters) => { for sub_filter in filters { if user_matches_filter(user, sub_filter) { return true; } } false } LdapFilter::Not(sub_filter) => !user_matches_filter(user, sub_filter), 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" ), } }