Files
lldap/crates/ldap/src/search.rs
T
copilot-swe-agent[bot] e5c28a61d9 ldap: Fix LDAP base scope search to return NoSuchObject for non-existent entries
Added logic to return LdapResultCode::NoSuchObject (error 32) when a base scope
search returns no results, instead of returning Success with zero entries. This
aligns with RFC 4511 LDAP specification.
2025-11-16 15:20:06 +01:00

1531 lines
66 KiB
Rust

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<UserAndGroups>, Vec<Group>),
Raw(Vec<LdapOp>),
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<S: Into<String>>(
base: &str,
filter: LdapFilter,
attrs: Vec<S>,
) -> 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<InternalSearchResults> {
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<Vec<LdapOp>> {
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<Utc>,
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> = Utc.from_utc_datetime(&timestamp_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<LdapOp> = 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(_)));
}
}