use crate::core::{ error::{LdapError, LdapResult}, group::{convert_groups_to_ldap_op, get_groups_list}, user::{convert_users_to_ldap_op, get_user_list}, utils::{LdapInfo, LdapSchemaDescription, is_subtree, parse_distinguished_name}, }; use chrono::Utc; use ldap3_proto::{ LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, LdapSearchScope, proto::{ LdapDerefAliases, LdapOp, LdapResult as LdapResultOp, LdapSearchRequest, OID_PASSWORD_MODIFY, OID_WHOAMI, }, }; use lldap_access_control::UserAndGroupListerBackendHandler; use lldap_domain::{ public_schema::PublicSchema, types::{Group, UserAndGroups}, }; use tracing::{debug, warn}; #[derive(Debug)] enum SearchScope { Global, Users, Groups, User(LdapFilter), Group(LdapFilter), UserOuOnly, GroupOuOnly, Unknown, Invalid, } enum InternalSearchResults { UsersAndGroups(Vec, Vec), Raw(Vec), Empty, } fn get_search_scope( base_dn: &[(String, String)], dn_parts: &[(String, String)], ldap_scope: &LdapSearchScope, ) -> SearchScope { let base_dn_len = base_dn.len(); if !is_subtree(dn_parts, base_dn) { SearchScope::Invalid } else if dn_parts.len() == base_dn_len { SearchScope::Global } else if dn_parts.len() == base_dn_len + 1 && dn_parts[0] == ("ou".to_string(), "people".to_string()) { if matches!(ldap_scope, LdapSearchScope::Base) { SearchScope::UserOuOnly } else { SearchScope::Users } } else if dn_parts.len() == base_dn_len + 1 && dn_parts[0] == ("ou".to_string(), "groups".to_string()) { if matches!(ldap_scope, LdapSearchScope::Base) { SearchScope::GroupOuOnly } else { SearchScope::Groups } } else if dn_parts.len() == base_dn_len + 2 && dn_parts[1] == ("ou".to_string(), "people".to_string()) { SearchScope::User(LdapFilter::Equality( dn_parts[0].0.clone(), dn_parts[0].1.clone(), )) } else if dn_parts.len() == base_dn_len + 2 && dn_parts[1] == ("ou".to_string(), "groups".to_string()) { SearchScope::Group(LdapFilter::Equality( dn_parts[0].0.clone(), dn_parts[0].1.clone(), )) } else { SearchScope::Unknown } } pub(crate) fn make_search_request>( base: &str, filter: LdapFilter, attrs: Vec, ) -> LdapSearchRequest { LdapSearchRequest { base: base.to_string(), scope: LdapSearchScope::Subtree, aliases: LdapDerefAliases::Never, sizelimit: 0, timelimit: 0, typesonly: false, filter, attrs: attrs.into_iter().map(Into::into).collect(), } } pub(crate) fn make_search_success() -> LdapOp { make_search_error(LdapResultCode::Success, "".to_string()) } pub(crate) fn make_search_error(code: LdapResultCode, message: String) -> LdapOp { LdapOp::SearchResultDone(LdapResultOp { code, matcheddn: "".to_string(), message, referral: vec![], }) } pub(crate) fn root_dse_response(base_dn: &str) -> LdapOp { LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "".to_string(), attributes: vec![ LdapPartialAttribute { atype: "objectClass".to_string(), vals: vec![b"top".to_vec()], }, LdapPartialAttribute { atype: "vendorName".to_string(), vals: vec![b"LLDAP".to_vec()], }, LdapPartialAttribute { atype: "vendorVersion".to_string(), vals: vec![ concat!("lldap_", env!("CARGO_PKG_VERSION")) .to_string() .into_bytes(), ], }, LdapPartialAttribute { atype: "supportedLDAPVersion".to_string(), vals: vec![b"3".to_vec()], }, LdapPartialAttribute { atype: "supportedExtension".to_string(), vals: vec![ OID_PASSWORD_MODIFY.as_bytes().to_vec(), OID_WHOAMI.as_bytes().to_vec(), ], }, LdapPartialAttribute { atype: "supportedControl".to_string(), vals: vec![], }, LdapPartialAttribute { atype: "supportedFeatures".to_string(), // Attribute "+" vals: vec![b"1.3.6.1.4.1.4203.1.5.1".to_vec()], }, LdapPartialAttribute { atype: "defaultNamingContext".to_string(), vals: vec![base_dn.to_string().into_bytes()], }, LdapPartialAttribute { atype: "namingContexts".to_string(), vals: vec![base_dn.to_string().into_bytes()], }, LdapPartialAttribute { atype: "isGlobalCatalogReady".to_string(), vals: vec![b"false".to_vec()], }, LdapPartialAttribute { atype: "subschemaSubentry".to_string(), vals: vec![b"cn=Subschema".to_vec()], }, ], }) } pub fn make_ldap_subschema_entry(schema: PublicSchema) -> LdapOp { let ldap_schema_description: LdapSchemaDescription = LdapSchemaDescription::from(schema); let current_time_utc = Utc::now().format("%Y%m%d%H%M%SZ").to_string().into_bytes(); LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "cn=Subschema".to_string(), attributes: vec![ LdapPartialAttribute { atype: "structuralObjectClass".to_string(), vals: vec![b"subentry".to_vec()], }, LdapPartialAttribute { atype: "objectClass".to_string(), vals: vec![b"top".to_vec(), b"subentry".to_vec(), b"subschema".to_vec(), b"extensibleObject".to_vec()], }, LdapPartialAttribute { atype: "cn".to_string(), vals: vec![b"Subschema".to_vec()], }, LdapPartialAttribute { atype: "createTimestamp".to_string(), vals: vec![current_time_utc.to_vec()], }, LdapPartialAttribute { atype: "modifyTimestamp".to_string(), vals: vec![current_time_utc.to_vec()], }, LdapPartialAttribute { atype: "ldapSyntaxes".to_string(), vals: vec![ b"( 1.3.6.1.1.16.1 DESC 'UUID' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.3 DESC 'Attribute Type Description' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.12 DESC 'Distinguished Name' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.34 DESC 'Name And Optional UID' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.37 DESC 'Object Class Description' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.38 DESC 'OID' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.54 DESC 'LDAP Syntax Description' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.58 DESC 'Substring Assertion' )".to_vec(), ], }, LdapPartialAttribute { atype: "matchingRules".to_string(), vals: vec![ b"( 1.3.6.1.1.16.2 NAME 'UUIDMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), b"( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), b"( 2.5.13.0 NAME 'objectIdentifierMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), b"( 2.5.13.1 NAME 'distinguishedNameMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), b"( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), b"( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )".to_vec(), b"( 2.5.13.23 NAME 'uniqueMemberMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), b"( 2.5.13.27 NAME 'generalizedTimeMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 2.5.13.28 NAME 'generalizedTimeOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), ], }, LdapPartialAttribute { atype: "attributeTypes".to_string(), vals: { let hardcoded_attributes = [ b"( 0.9.2342.19200300.100.1.1 NAME ( 'uid' 'userid' 'user_id' ) DESC 'RFC4519: user identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE NO-USER-MODIFICATION )".to_vec(), b"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 NO-USER-MODIFICATION USAGE dSAOperation X-ORIGIN 'iPlanet Delegated Administrator' )".to_vec(), b"( 1.3.6.1.1.16.4 NAME ( 'entryUUID' 'uuid' ) DESC 'UUID of the entry' EQUALITY UUIDMatch ORDERING UUIDOrderingMatch SYNTAX 1.3.6.1.1.16.1 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC4512: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )".to_vec(), b"( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), b"( 2.5.4.3 NAME ( 'cn' 'commonName' 'display_name' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), b"( 2.5.4.4 NAME ( 'sn' 'surname' 'last_name' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), b"( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name )".to_vec(), b"( 2.5.4.41 NAME 'name' DESC 'RFC4519: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )".to_vec(), b"( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC4519: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), b"( 2.5.4.50 NAME ( 'uniqueMember' 'member' ) DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), b"( 2.5.18.1 NAME ( 'createTimestamp' 'creation_date' ) DESC 'RFC4512: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC4512: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC4512: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )".to_vec(), b"( 2.5.21.6 NAME 'objectClasses' DESC 'RFC4512: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )".to_vec(), b"( 2.5.21.9 NAME 'structuralObjectClass' DESC 'RFC4512: structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 10.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), b"( 10.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), b"( 10.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), b"( 10.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), ]; hardcoded_attributes.into_iter().chain( ldap_schema_description .formatted_attribute_list( 4, // The number of hardcoded attributes starting with "10." (LLDAP custom range) vec!["creation_date", "display_name", "last_name", "user_id", "uuid"] ) ).collect() } }, LdapPartialAttribute { atype: "objectClasses".to_string(), vals: vec![ format!( "( 3.0 NAME ( {} ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( {} ) MAY ( {} ) )", ldap_schema_description.user_object_classes().format_for_ldap_schema_description(), ldap_schema_description.required_user_attributes().format_for_ldap_schema_description(), ldap_schema_description.optional_user_attributes().format_for_ldap_schema_description(), ).into_bytes(), format!( "( 3.1 NAME ( {} ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( {} ) MAY ( {} ) )", ldap_schema_description.group_object_classes().format_for_ldap_schema_description(), ldap_schema_description.required_group_attributes().format_for_ldap_schema_description(), ldap_schema_description.optional_group_attributes().format_for_ldap_schema_description(), ).into_bytes(), ], }, LdapPartialAttribute { atype: "subschemaSubentry".to_string(), vals: vec![b"cn=Subschema".to_vec()], }, ], }) } pub(crate) fn is_root_dse_request(request: &LdapSearchRequest) -> bool { request.base.is_empty() && request.scope == LdapSearchScope::Base && matches!(&request.filter, LdapFilter::Present(attr) if attr.eq_ignore_ascii_case("objectclass")) } pub(crate) fn is_subschema_entry_request(request: &LdapSearchRequest) -> bool { request.base == "cn=Subschema" && request.scope == LdapSearchScope::Base } async fn do_search_internal( ldap_info: &LdapInfo, backend_handler: &impl UserAndGroupListerBackendHandler, request: &LdapSearchRequest, schema: &PublicSchema, ) -> LdapResult { let dn_parts = parse_distinguished_name(&request.base.to_ascii_lowercase())?; let scope = get_search_scope(&ldap_info.base_dn, &dn_parts, &request.scope); debug!(?request.base, ?scope); // Disambiguate the lifetimes. fn cast<'a, T, R>(x: T) -> T where T: Fn(&'a LdapFilter) -> R + 'a, { x } let get_user_list = cast(async |filter: &LdapFilter| { let need_groups = request .attrs .iter() .any(|s| s.eq_ignore_ascii_case("memberof")); get_user_list( ldap_info, filter, need_groups, &request.base, backend_handler, schema, ) .await }); let get_group_list = cast(|filter: &LdapFilter| async { get_groups_list(ldap_info, filter, &request.base, backend_handler, schema).await }); Ok(match scope { SearchScope::Global => { let users = get_user_list(&request.filter).await; let groups = get_group_list(&request.filter).await; match (users, groups) { (Ok(users), Err(e)) => { warn!("Error while getting groups: {:#}", e); InternalSearchResults::UsersAndGroups(users, Vec::new()) } (Err(e), Ok(groups)) => { warn!("Error while getting users: {:#}", e); InternalSearchResults::UsersAndGroups(Vec::new(), groups) } (Err(user_error), Err(_)) => InternalSearchResults::Raw(vec![make_search_error( user_error.code, user_error.message, )]), (Ok(users), Ok(groups)) => InternalSearchResults::UsersAndGroups(users, groups), } } SearchScope::Users => { InternalSearchResults::UsersAndGroups(get_user_list(&request.filter).await?, Vec::new()) } SearchScope::Groups => InternalSearchResults::UsersAndGroups( Vec::new(), get_group_list(&request.filter).await?, ), SearchScope::User(filter) => { let filter = LdapFilter::And(vec![request.filter.clone(), filter]); InternalSearchResults::UsersAndGroups(get_user_list(&filter).await?, Vec::new()) } SearchScope::Group(filter) => { let filter = LdapFilter::And(vec![request.filter.clone(), filter]); InternalSearchResults::UsersAndGroups(Vec::new(), get_group_list(&filter).await?) } SearchScope::UserOuOnly | SearchScope::GroupOuOnly => { InternalSearchResults::Raw(vec![LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: request.base.clone(), attributes: vec![LdapPartialAttribute { atype: "objectClass".to_owned(), vals: vec![b"top".to_vec(), b"organizationalUnit".to_vec()], }], })]) } SearchScope::Unknown => { warn!( r#"The requested search tree "{}" matches neither the user subtree "ou=people,{}" nor the group subtree "ou=groups,{}""#, &request.base, &ldap_info.base_dn_str, &ldap_info.base_dn_str ); InternalSearchResults::Empty } SearchScope::Invalid => { // Search path is not in our tree, just return an empty success. warn!( "The specified search tree {:?} is not under the common subtree {:?}", &dn_parts, &ldap_info.base_dn ); InternalSearchResults::Empty } }) } pub async fn do_search( backend_handler: &impl UserAndGroupListerBackendHandler, ldap_info: &LdapInfo, request: &LdapSearchRequest, ) -> LdapResult> { let schema = PublicSchema::from(backend_handler.get_schema().await.map_err(|e| LdapError { code: LdapResultCode::OperationsError, message: format!("Unable to get schema: {e:#}"), })?); let search_results = do_search_internal(ldap_info, backend_handler, request, &schema).await?; let mut results = match search_results { InternalSearchResults::UsersAndGroups(users, groups) => { convert_users_to_ldap_op(users, &request.attrs, ldap_info, &schema) .chain(convert_groups_to_ldap_op( groups, &request.attrs, ldap_info, backend_handler.user_filter(), &schema, )) .collect() } InternalSearchResults::Raw(raw_results) => raw_results, InternalSearchResults::Empty => Vec::new(), }; // RFC 4511: When performing a base scope search, if the entry doesn't exist, // we should return NoSuchObject instead of Success with zero entries if results.is_empty() && request.scope == LdapSearchScope::Base { return Err(LdapError { code: LdapResultCode::NoSuchObject, message: "".to_string(), }); } if !matches!(results.last(), Some(LdapOp::SearchResultDone(_))) { results.push(make_search_success()); } Ok(results) } #[cfg(test)] mod tests { use super::*; use crate::{ core::error::LdapError, handler::tests::{ make_group_search_request, make_user_search_request, setup_bound_admin_handler, setup_bound_readonly_handler, }, }; use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc}; use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope, LdapSubstringFilter}; use lldap_domain::{ schema::{AttributeList, AttributeSchema, Schema}, types::{ Attribute, AttributeName, AttributeType, GroupId, JpegPhoto, LdapObjectClass, User, UserId, }, uuid, }; use lldap_domain_handlers::handler::*; use lldap_domain_model::model::UserColumn; use lldap_test_utils::MockTestBackendHandler; use mockall::predicate::eq; use pretty_assertions::assert_eq; fn assert_timestamp_within_margin( timestamp_bytes: &[u8], base_timestamp_dt: DateTime, time_margin: Duration, ) { let timestamp_str = std::str::from_utf8(timestamp_bytes).expect("Invalid conversion from UTF-8 to string"); let timestamp_naive = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%SZ") .expect("Invalid timestamp format"); let timestamp_dt: DateTime = Utc.from_utc_datetime(×tamp_naive); let within_range = (base_timestamp_dt - timestamp_dt).abs() <= time_margin; assert!( within_range, "Timestamp not within range: expected within [{} - {}], got [{}]", base_timestamp_dt - time_margin, base_timestamp_dt + time_margin, timestamp_dt ); } #[tokio::test] async fn test_search_root_dse() { let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = LdapSearchRequest { base: "".to_string(), scope: LdapSearchScope::Base, aliases: LdapDerefAliases::Never, sizelimit: 0, timelimit: 0, typesonly: false, filter: LdapFilter::Present("objectClass".to_string()), attrs: vec!["supportedExtension".to_string()], }; assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![ root_dse_response("dc=example,dc=com"), make_search_success() ]) ); } #[tokio::test] async fn test_subschema_response() { let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = LdapSearchRequest { base: "cn=Subschema".to_string(), scope: LdapSearchScope::Base, aliases: LdapDerefAliases::Never, sizelimit: 0, timelimit: 0, typesonly: false, filter: LdapFilter::Present("objectClass".to_string()), attrs: vec!["supportedExtension".to_string()], }; let actual_reponse: Vec = ldap_handler.do_search_or_dse(&request).await.unwrap(); let LdapOp::SearchResultEntry(search_result_entry) = &actual_reponse[0] else { panic!("Expected SearchResultEntry"); }; let attrs = &search_result_entry.attributes; assert_eq!(attrs.len(), 10); assert_eq!(search_result_entry.dn, "cn=Subschema".to_owned()); assert_eq!( attrs[0], LdapPartialAttribute { atype: "structuralObjectClass".to_owned(), vals: vec![b"subentry".to_vec()] } ); assert_eq!( attrs[1], LdapPartialAttribute { atype: "objectClass".to_owned(), vals: vec![ b"top".to_vec(), b"subentry".to_vec(), b"subschema".to_vec(), b"extensibleObject".to_vec() ] } ); assert_eq!( attrs[2], LdapPartialAttribute { atype: "cn".to_owned(), vals: vec![b"Subschema".to_vec()] } ); let check_timestamp_attribute = |attr: &LdapPartialAttribute, expected_type: &str| { assert_eq!(attr.atype, expected_type); assert_eq!(attr.vals.len(), 1); assert_timestamp_within_margin(&attr.vals[0], Utc::now(), Duration::seconds(300)); }; check_timestamp_attribute(&attrs[3], "createTimestamp"); check_timestamp_attribute(&attrs[4], "modifyTimestamp"); assert_eq!( attrs[5], LdapPartialAttribute { atype: "ldapSyntaxes".to_owned(), vals: vec![ b"( 1.3.6.1.1.16.1 DESC 'UUID' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.3 DESC 'Attribute Type Description' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.12 DESC 'Distinguished Name' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )" .to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.34 DESC 'Name And Optional UID' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.37 DESC 'Object Class Description' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.38 DESC 'OID' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.54 DESC 'LDAP Syntax Description' )".to_vec(), b"( 1.3.6.1.4.1.1466.115.121.1.58 DESC 'Substring Assertion' )".to_vec(), ] } ); assert_eq!( attrs[6], LdapPartialAttribute { atype: "matchingRules".to_string(), vals: vec![ b"( 1.3.6.1.1.16.2 NAME 'UUIDMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), b"( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), b"( 2.5.13.0 NAME 'objectIdentifierMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), b"( 2.5.13.1 NAME 'distinguishedNameMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), b"( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), b"( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )".to_vec(), b"( 2.5.13.23 NAME 'uniqueMemberMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), b"( 2.5.13.27 NAME 'generalizedTimeMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 2.5.13.28 NAME 'generalizedTimeOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), ] } ); assert_eq!( attrs[7], LdapPartialAttribute { atype: "attributeTypes".to_owned(), vals: vec![ b"( 0.9.2342.19200300.100.1.1 NAME ( 'uid' 'userid' 'user_id' ) DESC 'RFC4519: user identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE NO-USER-MODIFICATION )".to_vec(), b"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 NO-USER-MODIFICATION USAGE dSAOperation X-ORIGIN 'iPlanet Delegated Administrator' )".to_vec(), b"( 1.3.6.1.1.16.4 NAME ( 'entryUUID' 'uuid' ) DESC 'UUID of the entry' EQUALITY UUIDMatch ORDERING UUIDOrderingMatch SYNTAX 1.3.6.1.1.16.1 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC4512: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )".to_vec(), b"( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), b"( 2.5.4.3 NAME ( 'cn' 'commonName' 'display_name' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), b"( 2.5.4.4 NAME ( 'sn' 'surname' 'last_name' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), b"( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name )".to_vec(), b"( 2.5.4.41 NAME 'name' DESC 'RFC4519: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )".to_vec(), b"( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC4519: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), b"( 2.5.4.50 NAME ( 'uniqueMember' 'member' ) DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), b"( 2.5.18.1 NAME ( 'createTimestamp' 'creation_date' ) DESC 'RFC4512: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC4512: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC4512: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )".to_vec(), b"( 2.5.21.6 NAME 'objectClasses' DESC 'RFC4512: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )".to_vec(), b"( 2.5.21.9 NAME 'structuralObjectClass' DESC 'RFC4512: structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), b"( 10.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), b"( 10.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), b"( 10.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), b"( 10.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), b"( 10.4 NAME 'avatar' DESC 'LLDAP: builtin attribute' SUP JpegPhoto )".to_vec(), b"( 10.5 NAME 'first_name' DESC 'LLDAP: builtin attribute' SUP String )" .to_vec(), b"( 10.6 NAME 'mail' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(), b"( 10.7 NAME 'modified_date' DESC 'LLDAP: builtin attribute' SUP DateTime )".to_vec(), b"( 10.8 NAME 'password_modified_date' DESC 'LLDAP: builtin attribute' SUP DateTime )".to_vec(), b"( 10.9 NAME 'group_id' DESC 'LLDAP: builtin attribute' SUP Integer )" .to_vec(), b"( 10.10 NAME 'modified_date' DESC 'LLDAP: builtin attribute' SUP DateTime )".to_vec(), ] } ); assert_eq!(attrs[8], LdapPartialAttribute { atype: "objectClasses".to_owned(), vals: vec![ b"( 3.0 NAME ( 'inetOrgPerson' 'posixAccount' 'mailAccount' 'person' 'customUserClass' ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( mail $ user_id ) MAY ( avatar $ creation_date $ display_name $ first_name $ last_name $ modified_date $ password_modified_date $ uuid ) )".to_vec(), b"( 3.1 NAME ( 'groupOfUniqueNames' 'groupOfNames' ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( display_name ) MAY ( creation_date $ group_id $ modified_date $ uuid ) )".to_vec(), ] } ); assert_eq!( attrs[9], LdapPartialAttribute { atype: "subschemaSubentry".to_owned(), vals: vec![b"cn=Subschema".to_vec()] } ); assert_eq!(actual_reponse[1], make_search_success()); } #[tokio::test] async fn test_search_groups_unsupported_substring() { let ldap_handler = setup_bound_readonly_handler(MockTestBackendHandler::new()).await; let request = make_group_search_request( LdapFilter::Substring("member".to_owned(), LdapSubstringFilter::default()), vec!["cn"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: r#"Unsupported group attribute for substring filter: "member""#.to_owned() }) ); } #[tokio::test] async fn test_search_groups_missing_attribute_substring() { let request = make_group_search_request( LdapFilter::Substring("nonexistent".to_owned(), LdapSubstringFilter::default()), vec!["cn"], ); let mut mock = MockTestBackendHandler::new(); mock.expect_list_groups() .with(eq(Some(false.into()))) .times(1) .return_once(|_| Ok(vec![])); let ldap_handler = setup_bound_readonly_handler(mock).await; assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![make_search_success()]), ); } #[tokio::test] async fn test_search_groups_error() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_groups() .with(eq(Some(GroupRequestFilter::Not(Box::new( GroupRequestFilter::DisplayName("group_2".into()), ))))) .times(1) .return_once(|_| { Err(lldap_domain_model::error::DomainError::InternalError( "Error getting groups".to_string(), )) }); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_group_search_request( LdapFilter::Or(vec![LdapFilter::Not(Box::new(LdapFilter::Equality( "displayname".to_string(), "group_2".to_string(), )))]), vec!["cn"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Err(LdapError{ code: LdapResultCode::Other, message: r#"Error while listing groups "ou=groups,dc=example,dc=com": Internal error: `Error getting groups`"#.to_string() }) ); } #[tokio::test] async fn test_search_groups_filter_error() { let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_group_search_request( LdapFilter::And(vec![LdapFilter::Approx( "whatever".to_owned(), "value".to_owned(), )]), vec!["cn"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: r#"Unsupported group filter: Approx("whatever", "value")"#.to_string() }) ); } #[tokio::test] async fn test_search_filters() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() .with( eq(Some(UserRequestFilter::Or(vec![ UserRequestFilter::Not(Box::new(UserRequestFilter::UserId(UserId::new("bob")))), UserRequestFilter::UserId("bob_1".to_string().into()), true.into(), true.into(), true.into(), UserRequestFilter::AttributeEquality( AttributeName::from("first_name"), "FirstName".to_string().into(), ), UserRequestFilter::AttributeEquality( AttributeName::from("first_name"), "firstname".to_string().into(), ), UserRequestFilter::UserIdSubString(SubStringFilter { initial: Some("iNIt".to_owned()), any: vec!["1".to_owned(), "2aA".to_owned()], final_: Some("finAl".to_owned()), }), UserRequestFilter::SubString( UserColumn::DisplayName, SubStringFilter { initial: Some("iNIt".to_owned()), any: vec!["1".to_owned(), "2aA".to_owned()], final_: Some("finAl".to_owned()), }, ), ]))), eq(false), ) .times(1) .return_once(|_, _| Ok(vec![])); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_user_search_request( LdapFilter::And(vec![LdapFilter::Or(vec![ LdapFilter::Not(Box::new(LdapFilter::Equality( "uid".to_string(), "bob".to_string(), ))), LdapFilter::Equality( "dn".to_string(), "uid=bob_1,ou=people,dc=example,dc=com".to_string(), ), LdapFilter::Equality( "dn".to_string(), "uid=bob_1,ou=groups,dc=example,dc=com".to_string(), ), LdapFilter::Equality("objectclass".to_string(), "persOn".to_string()), LdapFilter::Equality("objectclass".to_string(), "other".to_string()), LdapFilter::Present("objectClass".to_string()), LdapFilter::Present("uid".to_string()), LdapFilter::Present("unknown".to_string()), LdapFilter::Equality("givenname".to_string(), "FirstName".to_string()), LdapFilter::Equality("unknown_attribute".to_string(), "randomValue".to_string()), LdapFilter::Substring( "uid".to_owned(), LdapSubstringFilter { initial: Some("iNIt".to_owned()), any: vec!["1".to_owned(), "2aA".to_owned()], final_: Some("finAl".to_owned()), }, ), LdapFilter::Substring( "displayName".to_owned(), LdapSubstringFilter { initial: Some("iNIt".to_owned()), any: vec!["1".to_owned(), "2aA".to_owned()], final_: Some("finAl".to_owned()), }, ), ])]), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![make_search_success()]) ); } #[tokio::test] async fn test_search_unsupported_substring_filter() { let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_user_search_request( LdapFilter::Substring( "uuid".to_owned(), LdapSubstringFilter { initial: Some("iNIt".to_owned()), any: vec!["1".to_owned(), "2aA".to_owned()], final_: Some("finAl".to_owned()), }, ), vec!["objectClass"], ); ldap_handler.do_search_or_dse(&request).await.unwrap_err(); let request = make_user_search_request( LdapFilter::Substring( "givenname".to_owned(), LdapSubstringFilter { initial: Some("iNIt".to_owned()), any: vec!["1".to_owned(), "2aA".to_owned()], final_: Some("finAl".to_owned()), }, ), vec!["objectClass"], ); ldap_handler.do_search_or_dse(&request).await.unwrap_err(); } #[tokio::test] async fn test_search_member_of_filter() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() .with( eq(Some(UserRequestFilter::MemberOf("group_1".into()))), eq(false), ) .times(2) .returning(|_, _| Ok(vec![])); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_user_search_request( LdapFilter::Equality( "memberOf".to_string(), "cn=group_1, ou=groups, dc=example,dc=com".to_string(), ), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![make_search_success()]) ); let request = make_user_search_request( LdapFilter::Equality("memberOf".to_string(), "group_1".to_string()), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![make_search_success()]) ); } #[tokio::test] async fn test_search_member_of_filter_error() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() .with(eq(Some(UserRequestFilter::from(false))), eq(false)) .times(1) .returning(|_, _| Ok(vec![])); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_user_search_request( LdapFilter::Equality( "memberOf".to_string(), "cn=mygroup,dc=example,dc=com".to_string(), ), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, // The error is ignored, a warning is printed. Ok(vec![make_search_success()]) ); } #[tokio::test] async fn test_search_filters_lowercase() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() .with( eq(Some(UserRequestFilter::Not(Box::new( UserRequestFilter::Equality(UserColumn::DisplayName, "bob".to_string()), )))), eq(false), ) .times(1) .return_once(|_, _| { Ok(vec![UserAndGroups { user: User { user_id: UserId::new("bob_1"), ..Default::default() }, groups: None, }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_user_search_request( LdapFilter::And(vec![LdapFilter::Or(vec![LdapFilter::Not(Box::new( LdapFilter::Equality("displayname".to_string(), "bob".to_string()), ))])]), vec!["objectclass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![ LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), attributes: vec![LdapPartialAttribute { atype: "objectclass".to_string(), vals: vec![ b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), b"person".to_vec(), b"customUserClass".to_vec(), ] },] }), make_search_success() ]) ); } #[tokio::test] async fn test_search_filters_custom_object_class() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() .with(eq(Some(UserRequestFilter::from(true))), eq(false)) .times(1) .return_once(|_, _| { Ok(vec![UserAndGroups { user: User { user_id: UserId::new("bob_1"), ..Default::default() }, groups: None, }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_user_search_request( LdapFilter::Equality("objectClass".to_owned(), "CUSTOMuserCLASS".to_owned()), vec!["objectclass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![ LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), attributes: vec![LdapPartialAttribute { atype: "objectclass".to_string(), vals: vec![ b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), b"person".to_vec(), b"customUserClass".to_vec(), ] },] }), make_search_success() ]) ); } #[tokio::test] async fn test_search_both() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users().times(1).return_once(|_, _| { Ok(vec![UserAndGroups { user: User { user_id: UserId::new("bob_1"), email: "bob@bobmail.bob".into(), display_name: Some("Bôb Böbberson".to_string()), attributes: vec![ Attribute { name: "first_name".into(), value: "Bôb".to_string().into(), }, Attribute { name: "last_name".to_string().into(), value: "Böbberson".to_string().into(), }, ], ..Default::default() }, groups: None, }]) }); mock.expect_list_groups() .with(eq(Some(GroupRequestFilter::True))) .times(1) .return_once(|_| { Ok(vec![Group { id: GroupId(1), display_name: "group_1".into(), creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), users: vec![UserId::new("bob"), UserId::new("john")], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), attributes: Vec::new(), modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_search_request( "dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectClass", "dn", "cn"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![ LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), attributes: vec![ LdapPartialAttribute { atype: "cn".to_string(), vals: vec!["Bôb Böbberson".to_string().into_bytes()] }, LdapPartialAttribute { atype: "objectClass".to_string(), vals: vec![ b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), b"person".to_vec(), b"customUserClass".to_vec(), ], }, ], }), LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), attributes: vec![ LdapPartialAttribute { atype: "cn".to_string(), vals: vec![b"group_1".to_vec()] }, LdapPartialAttribute { atype: "objectClass".to_string(), vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], }, ], }), make_search_success(), ]) ); } #[tokio::test] async fn test_search_wildcards() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users().returning(|_, _| { Ok(vec![UserAndGroups { user: User { user_id: UserId::new("bob_1"), email: "bob@bobmail.bob".into(), display_name: Some("Bôb Böbberson".to_string()), attributes: vec![ Attribute { name: "avatar".into(), value: JpegPhoto::for_tests().into(), }, Attribute { name: "last_name".into(), value: "Böbberson".to_string().into(), }, ], uuid: uuid!("b4ac75e0-2900-3e21-926c-2f732c26b3fc"), ..Default::default() }, groups: None, }]) }); mock.expect_list_groups() .with(eq(Some(GroupRequestFilter::True))) .returning(|_| { Ok(vec![Group { id: GroupId(1), display_name: "group_1".into(), creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), users: vec![UserId::new("bob"), UserId::new("john")], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), attributes: Vec::new(), modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; // Test simple wildcard let request = make_search_request("dc=example,dc=com", LdapFilter::And(vec![]), vec!["*", "+"]); // all: "objectclass", "dn", "uid", "mail", "givenname", "sn", "cn" // Operational: "createtimestamp" let expected_result = Ok(vec![ LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), attributes: vec![ LdapPartialAttribute { atype: "avatar".to_string(), vals: vec![JpegPhoto::for_tests().into_bytes()], }, LdapPartialAttribute { atype: "cn".to_string(), vals: vec!["Bôb Böbberson".to_string().into_bytes()], }, LdapPartialAttribute { atype: "createtimestamp".to_string(), vals: vec![b"19700101000000Z".to_vec()], }, LdapPartialAttribute { atype: "entryuuid".to_string(), vals: vec![b"b4ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()], }, LdapPartialAttribute { atype: "jpegPhoto".to_string(), vals: vec![JpegPhoto::for_tests().into_bytes()], }, LdapPartialAttribute { atype: "last_name".to_string(), vals: vec!["Böbberson".to_string().into_bytes()], }, LdapPartialAttribute { atype: "mail".to_string(), vals: vec![b"bob@bobmail.bob".to_vec()], }, LdapPartialAttribute { atype: "objectclass".to_string(), vals: vec![ b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), b"person".to_vec(), b"customUserClass".to_vec(), ], }, LdapPartialAttribute { atype: "sn".to_string(), vals: vec!["Böbberson".to_string().into_bytes()], }, LdapPartialAttribute { atype: "uid".to_string(), vals: vec![b"bob_1".to_vec()], }, ], }), // "objectclass", "dn", "uid", "cn", "member", "uniquemember" LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), attributes: vec![ LdapPartialAttribute { atype: "cn".to_string(), vals: vec![b"group_1".to_vec()], }, LdapPartialAttribute { atype: "entryuuid".to_string(), vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()], }, //member / uniquemember : "uid={},ou=people,{}" LdapPartialAttribute { atype: "member".to_string(), vals: vec![ b"uid=bob,ou=people,dc=example,dc=com".to_vec(), b"uid=john,ou=people,dc=example,dc=com".to_vec(), ], }, LdapPartialAttribute { atype: "objectclass".to_string(), vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec()], }, // UID LdapPartialAttribute { atype: "uid".to_string(), vals: vec![b"group_1".to_vec()], }, LdapPartialAttribute { atype: "uniquemember".to_string(), vals: vec![ b"uid=bob,ou=people,dc=example,dc=com".to_vec(), b"uid=john,ou=people,dc=example,dc=com".to_vec(), ], }, ], }), make_search_success(), ]); assert_eq!( ldap_handler.do_search_or_dse(&request).await, expected_result ); let request2 = make_search_request( "dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectclass", "obJEctclaSS", "dn", "*", "*"], ); assert_eq!( ldap_handler.do_search_or_dse(&request2).await, expected_result ); let request3 = make_search_request( "dc=example,dc=com", LdapFilter::And(vec![]), vec!["*", "+", "+"], ); assert_eq!( ldap_handler.do_search_or_dse(&request3).await, expected_result ); let request4 = make_search_request("dc=example,dc=com", LdapFilter::And(vec![]), vec![""; 0]); assert_eq!( ldap_handler.do_search_or_dse(&request4).await, expected_result ); let request5 = make_search_request( "dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectclass", "dn", "uid", "*"], ); assert_eq!( ldap_handler.do_search_or_dse(&request5).await, expected_result ); } #[tokio::test] async fn test_search_wrong_base() { let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_search_request( "ou=users,dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![make_search_success()]) ); } #[tokio::test] async fn test_search_unsupported_filters() { let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_user_search_request( LdapFilter::Approx("uid".to_owned(), "value".to_owned()), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: r#"Unsupported user filter: Approx("uid", "value")"#.to_string() }) ); } #[tokio::test] async fn test_search_filter_non_attribute() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users() .with(eq(Some(true.into())), eq(false)) .times(1) .return_once(|_, _| Ok(vec![])); let ldap_handler = setup_bound_admin_handler(mock).await; let request = make_user_search_request( LdapFilter::Present("displayname".to_owned()), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![make_search_success()]) ); } #[tokio::test] async fn test_user_ou_search() { let ldap_handler = setup_bound_readonly_handler(MockTestBackendHandler::new()).await; let request = LdapSearchRequest { base: "ou=people,dc=example,dc=com".to_owned(), scope: LdapSearchScope::Base, aliases: LdapDerefAliases::Never, sizelimit: 0, timelimit: 0, typesonly: false, filter: LdapFilter::And(vec![]), attrs: Vec::new(), }; assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![ LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "ou=people,dc=example,dc=com".to_owned(), attributes: vec![LdapPartialAttribute { atype: "objectClass".to_owned(), vals: vec![b"top".to_vec(), b"organizationalUnit".to_vec()] }] }), make_search_success() ]) ); } #[tokio::test] async fn test_custom_attribute_read() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users().times(1).return_once(|_, _| { Ok(vec![UserAndGroups { user: User { user_id: UserId::new("test"), attributes: vec![Attribute { name: "nickname".into(), value: "Bob the Builder".to_string().into(), }], ..Default::default() }, groups: None, }]) }); mock.expect_list_groups().times(1).return_once(|_| { Ok(vec![Group { id: GroupId(1), display_name: "group".into(), creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), users: vec![UserId::new("bob")], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), attributes: vec![Attribute { name: "club_name".into(), value: "Breakfast Club".to_string().into(), }], modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); mock.expect_get_schema().returning(|| { Ok(Schema { user_attributes: AttributeList { attributes: vec![AttributeSchema { name: "nickname".into(), attribute_type: AttributeType::String, is_list: false, is_visible: true, is_editable: true, is_hardcoded: false, is_readonly: false, }], }, group_attributes: AttributeList { attributes: vec![AttributeSchema { name: "club_name".into(), attribute_type: AttributeType::String, is_list: false, is_visible: true, is_editable: true, is_hardcoded: false, is_readonly: false, }], }, extra_user_object_classes: vec![ LdapObjectClass::from("customUserClass"), LdapObjectClass::from("myUserClass"), ], extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")], }) }); let ldap_handler = setup_bound_readonly_handler(mock).await; let request = make_search_request( "dc=example,dc=com", LdapFilter::And(vec![]), vec!["uid", "nickname", "club_name"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, Ok(vec![ LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "uid=test,ou=people,dc=example,dc=com".to_string(), attributes: vec![ LdapPartialAttribute { atype: "nickname".to_owned(), vals: vec![b"Bob the Builder".to_vec()], }, LdapPartialAttribute { atype: "uid".to_owned(), vals: vec![b"test".to_vec()], }, ], }), LdapOp::SearchResultEntry(LdapSearchResultEntry { dn: "cn=group,ou=groups,dc=example,dc=com".to_owned(), attributes: vec![ LdapPartialAttribute { atype: "club_name".to_owned(), vals: vec![b"Breakfast Club".to_vec()], }, LdapPartialAttribute { atype: "uid".to_owned(), vals: vec![b"group".to_vec()], }, ], }), make_search_success() ]), ); } #[tokio::test] async fn test_search_base_scope_non_existent_user() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users().returning(|_, _| Ok(vec![])); let ldap_handler = setup_bound_admin_handler(mock).await; let request = LdapSearchRequest { scope: LdapSearchScope::Base, ..make_search_request( "uid=nonexistent,ou=people,dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectClass".to_string()], ) }; assert_eq!( ldap_handler.do_search_or_dse(&request).await, Err(LdapError { code: LdapResultCode::NoSuchObject, message: "".to_string(), }) ); } #[tokio::test] async fn test_search_base_scope_non_existent_group() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_groups().returning(|_| Ok(vec![])); let ldap_handler = setup_bound_admin_handler(mock).await; let request = LdapSearchRequest { scope: LdapSearchScope::Base, ..make_search_request( "uid=nonexistent,ou=groups,dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectClass".to_string()], ) }; assert_eq!( ldap_handler.do_search_or_dse(&request).await, Err(LdapError { code: LdapResultCode::NoSuchObject, message: "".to_string(), }) ); } #[tokio::test] async fn test_search_base_scope_existing_user() { let mut mock = MockTestBackendHandler::new(); mock.expect_list_users().returning(|_, _| { Ok(vec![UserAndGroups { user: User { user_id: UserId::new("bob"), ..Default::default() }, groups: None, }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; let request = LdapSearchRequest { scope: LdapSearchScope::Base, ..make_search_request( "uid=bob,ou=people,dc=example,dc=com", LdapFilter::And(vec![]), vec!["objectClass".to_string()], ) }; let results = ldap_handler.do_search_or_dse(&request).await.unwrap(); // Should have 2 results: SearchResultEntry and SearchResultDone assert_eq!(results.len(), 2); assert!(matches!(results[0], LdapOp::SearchResultEntry(_))); assert!(matches!(results[1], LdapOp::SearchResultDone(_))); } }