Repository: lldap/lldap (language: rust)

Issue:
[BUG] LDAP: Filtering custom attributes by value doesn't work
**Describe the bug**
We search with the straight-up string given by LDAP, whereas we should use the schema to parse the attribute value and serialize it.

Note that repeated attributes cannot be searched by values.


Discussion / hints:

Fix patch:
diff --git a/server/src/domain/deserialize.rs b/server/src/domain/deserialize.rs
new file mode 100644
index 00000000..834b0fd4
--- /dev/null
+++ b/server/src/domain/deserialize.rs
@@ -0,0 +1,50 @@
+use crate::domain::types::{AttributeType, JpegPhoto, Serialized};
+use anyhow::{bail, Context as AnyhowContext};
+
+pub fn deserialize_attribute_value(
+    value: &[String],
+    typ: AttributeType,
+    is_list: bool,
+) -> anyhow::Result<Serialized> {
+    if !is_list && value.len() != 1 {
+        bail!("Attribute is not a list, but multiple values were provided",);
+    }
+    let parse_int = |value: &String| -> anyhow::Result<i64> {
+        value
+            .parse::<i64>()
+            .with_context(|| format!("Invalid integer value {}", value))
+    };
+    let parse_date = |value: &String| -> anyhow::Result<chrono::NaiveDateTime> {
+        Ok(chrono::DateTime::parse_from_rfc3339(value)
+            .with_context(|| format!("Invalid date value {}", value))?
+            .naive_utc())
+    };
+    let parse_photo = |value: &String| -> anyhow::Result<JpegPhoto> {
+        JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")
+    };
+    Ok(match (typ, is_list) {
+        (AttributeType::String, false) => Serialized::from(&value[0]),
+        (AttributeType::String, true) => Serialized::from(&value),
+        (AttributeType::Integer, false) => Serialized::from(&parse_int(&value[0])?),
+        (AttributeType::Integer, true) => Serialized::from(
+            &value
+                .iter()
+                .map(parse_int)
+                .collect::<anyhow::Result<Vec<_>>>()?,
+        ),
+        (AttributeType::DateTime, false) => Serialized::from(&parse_date(&value[0])?),
+        (AttributeType::DateTime, true) => Serialized::from(
+            &value
+                .iter()
+                .map(parse_date)
+                .collect::<anyhow::Result<Vec<_>>>()?,
+        ),
+        (AttributeType::JpegPhoto, false) => Serialized::from(&parse_photo(&value[0])?),
+        (AttributeType::JpegPhoto, true) => Serialized::from(
+            &value
+                .iter()
+                .map(parse_photo)
+                .collect::<anyhow::Result<Vec<_>>>()?,
+        ),
+    })
+}
diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs
index 8515885c..0b4550c9 100644
--- a/server/src/domain/handler.rs
+++ b/server/src/domain/handler.rs
@@ -2,7 +2,7 @@ use crate::domain::{
     error::Result,
     types::{
         AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
-        GroupName, JpegPhoto, User, UserAndGroups, UserColumn, UserId, Uuid,
+        GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid,
     },
 };
 use async_trait::async_trait;
@@ -54,7 +54,7 @@ pub enum UserRequestFilter {
     UserId(UserId),
     UserIdSubString(SubStringFilter),
     Equality(UserColumn, String),
-    AttributeEquality(AttributeName, String),
+    AttributeEquality(AttributeName, Serialized),
     SubString(UserColumn, SubStringFilter),
     // Check if a user belongs to a group identified by name.
     MemberOf(GroupName),
diff --git a/server/src/domain/ldap/group.rs b/server/src/domain/ldap/group.rs
index 9fe15de0..c769e6c0 100644
--- a/server/src/domain/ldap/group.rs
+++ b/server/src/domain/ldap/group.rs
@@ -14,7 +14,7 @@ use super::{
     error::LdapResult,
     utils::{
         expand_attribute_wildcards, get_custom_attribute, get_group_id_from_distinguished_name,
-        get_user_id_from_distinguished_name, map_group_field, LdapInfo,
+        get_user_id_from_distinguished_name, map_group_field, GroupFieldType, LdapInfo,
     },
 };
 
@@ -124,8 +124,9 @@ fn make_ldap_search_group_result_entry(
 fn convert_group_filter(
     ldap_info: &LdapInfo,
     filter: &LdapFilter,
+    schema: &PublicSchema,
 ) -> LdapResult<GroupRequestFilter> {
-    let rec = |f| convert_group_filter(ldap_info, f);
+    let rec = |f| convert_group_filter(ldap_info, f, schema);
     match filter {
         LdapFilter::Equality(field, value) => {
             let field = AttributeName::from(field.as_str());
@@ -153,9 +154,11 @@ fn convert_group_filter(
                     warn!("Invalid dn filter on group: {}", value);
                     GroupRequestFilter::from(false)
                 })),
-                _ => match map_group_field(&field) {
-                    Some("display_name") => Ok(GroupRequestFilter::DisplayName(value.into())),
-                    Some("uuid") => Ok(GroupRequestFilter::Uuid(
+                _ => match map_group_field(&field, schema) {
+                    GroupFieldType::DisplayName => {
+                        Ok(GroupRequestFilter::DisplayName(value.into()))
+                    }
+                    GroupFieldType::Uuid => Ok(GroupRequestFilter::Uuid(
                         Uuid::try_from(value.as_str()).map_err(|e| LdapError {
                             code: LdapResultCode::InappropriateMatching,
                             message: format!("Invalid UUID: {:#}", e),
@@ -187,13 +190,13 @@ fn convert_group_filter(
                 field.as_str() == "objectclass"
                     || field.as_str() == "dn"
                     || field.as_str() == "distinguishedname"
-                    || map_group_field(&field).is_some(),
+                    || !matches!(map_group_field(&field, schema), GroupFieldType::NoMatch),
             ))
         }
         LdapFilter::Substring(field, substring_filter) => {
             let field = AttributeName::from(field.as_str());
-            match map_group_field(&field) {
-                Some("display_name") => Ok(GroupRequestFilter::DisplayNameSubString(
+            match map_group_field(&field, schema) {
+                GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayNameSubString(
                     substring_filter.clone().into(),
                 )),
                 _ => Err(LdapError {
@@ -218,8 +221,9 @@ pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
     ldap_filter: &LdapFilter,
     base: &str,
     backend: &Backend,
+    schema: &PublicSchema,
 ) -> LdapResult<Vec<Group>> {
-    let filters = convert_group_filter(ldap_info, ldap_filter)?;
+    let filters = convert_group_filter(ldap_info, ldap_filter, schema)?;
     debug!(?filters);
     backend
         .list_groups(Some(filters))
diff --git a/server/src/domain/ldap/user.rs b/server/src/domain/ldap/user.rs
index 1ffce50e..23b92a12 100644
--- a/server/src/domain/ldap/user.rs
+++ b/server/src/domain/ldap/user.rs
@@ -5,6 +5,7 @@ use ldap3_proto::{
 use tracing::{debug, instrument, warn};
 
 use crate::domain::{
+    deserialize::deserialize_attribute_value,
     handler::{UserListerBackendHandler, UserRequestFilter},
     ldap::{
         error::{LdapError, LdapResult},
@@ -14,7 +15,7 @@ use crate::domain::{
         },
     },
     schema::{PublicSchema, SchemaUserAttributeExtractor},
-    types::{AttributeName, GroupDetails, User, UserAndGroups, UserColumn, UserId},
+    types::{AttributeName, AttributeType, GroupDetails, User, UserAndGroups, UserColumn, UserId},
 };
 
 pub fn get_user_attribute(
@@ -150,8 +151,26 @@ fn make_ldap_search_user_result_entry(
     }
 }
 
-fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<UserRequestFilter> {
-    let rec = |f| convert_user_filter(ldap_info, f);
+fn get_user_attribute_equality_filter(
+    field: &AttributeName,
+    typ: AttributeType,
+    is_list: bool,
+    value: &str,
+) -> LdapResult<UserRequestFilter> {
+    deserialize_attribute_value(&[value.to_owned()], typ, is_list)
+        .map_err(|e| LdapError {
+            code: LdapResultCode::Other,
+            message: format!("Invalid value for attribute {}: {}", field, e),
+        })
+        .map(|v| UserRequestFilter::AttributeEquality(field.clone(), v))
+}
+
+fn convert_user_filter(
+    ldap_info: &LdapInfo,
+    filter: &LdapFilter,
+    schema: &PublicSchema,
+) -> LdapResult<UserRequestFilter> {
+    let rec = |f| convert_user_filter(ldap_info, f, schema);
     match filter {
         LdapFilter::And(filters) => Ok(UserRequestFilter::And(
             filters.iter().map(rec).collect::<LdapResult<_>>()?,
@@ -184,17 +203,16 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
                     warn!("Invalid dn filter on user: {}", value);
                     UserRequestFilter::from(false)
                 })),
-                _ => match map_user_field(&field) {
+                _ => match map_user_field(&field, schema) {
                     UserFieldType::PrimaryField(UserColumn::UserId) => {
                         Ok(UserRequestFilter::UserId(UserId::new(value)))
                     }
                     UserFieldType::PrimaryField(field) => {
                         Ok(UserRequestFilter::Equality(field, value.clone()))
                     }
-                    UserFieldType::Attribute(field) => Ok(UserRequestFilter::AttributeEquality(
-                        AttributeName::from(field),
-                        value.clone(),
-                    )),
+                    UserFieldType::Attribute(field, typ, is_list) => {
+                        get_user_attribute_equality_filter(&field, typ, is_list, value)
+                    }
                     UserFieldType::NoMatch => {
                         if !ldap_info.ignored_user_attributes.contains(&field) {
                             warn!(
@@ -215,17 +233,17 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
                 field.as_str() == "objectclass"
                     || field.as_str() == "dn"
                     || field.as_str() == "distinguishedname"
-                    || !matches!(map_user_field(&field), UserFieldType::NoMatch),
+                    || !matches!(map_user_field(&field, schema), UserFieldType::NoMatch),
             ))
         }
         LdapFilter::Substring(field, substring_filter) => {
             let field = AttributeName::from(field.as_str());
-            match map_user_field(&field) {
+            match map_user_field(&field, schema) {
                 UserFieldType::PrimaryField(UserColumn::UserId) => Ok(
                     UserRequestFilter::UserIdSubString(substring_filter.clone().into()),
                 ),
                 UserFieldType::NoMatch
-                | UserFieldType::Attribute(_)
+                | UserFieldType::Attribute(_, _, _)
                 | UserFieldType::PrimaryField(UserColumn::CreationDate)
                 | UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError {
                     code: LdapResultCode::UnwillingToPerform,
@@ -258,8 +276,9 @@ pub async fn get_user_list<Backend: UserListerBackendHandler>(
     request_groups: bool,
     base: &str,
     backend: &Backend,
+    schema: &PublicSchema,
 ) -> LdapResult<Vec<UserAndGroups>> {
-    let filters = convert_user_filter(ldap_info, ldap_filter)?;
+    let filters = convert_user_filter(ldap_info, ldap_filter, schema)?;
     debug!(?filters);
     backend
         .list_users(Some(filters), request_groups)
diff --git a/server/src/domain/ldap/utils.rs b/server/src/domain/ldap/utils.rs
index f0579570..ad61225f 100644
--- a/server/src/domain/ldap/utils.rs
+++ b/server/src/domain/ldap/utils.rs
@@ -159,34 +159,66 @@ pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)])
 pub enum UserFieldType {
     NoMatch,
     PrimaryField(UserColumn),
-    Attribute(&'static str),
+    Attribute(AttributeName, AttributeType, bool),
 }
 
-pub fn map_user_field(field: &AttributeName) -> UserFieldType {
+pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserFieldType {
     match field.as_str() {
         "uid" | "user_id" | "id" => UserFieldType::PrimaryField(UserColumn::UserId),
         "mail" | "email" => UserFieldType::PrimaryField(UserColumn::Email),
         "cn" | "displayname" | "display_name" => {
             UserFieldType::PrimaryField(UserColumn::DisplayName)
         }
-        "givenname" | "first_name" | "firstname" => UserFieldType::Attribute("first_name"),
-        "sn" | "last_name" | "lastname" => UserFieldType::Attribute("last_name"),
-        "avatar" | "jpegphoto" => UserFieldType::Attribute("avatar"),
+        "givenname" | "first_name" | "firstname" => UserFieldType::Attribute(
+            AttributeName::from("first_name"),
+            AttributeType::String,
+            false,
+        ),
+        "sn" | "last_name" | "lastname" => UserFieldType::Attribute(
+            AttributeName::from("last_name"),
+            AttributeType::String,
+            false,
+        ),
+        "avatar" | "jpegphoto" => UserFieldType::Attribute(
+            AttributeName::from("avatar"),
+            AttributeType::JpegPhoto,
+            false,
+        ),
         "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
             UserFieldType::PrimaryField(UserColumn::CreationDate)
         }
         "entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid),
-        _ => UserFieldType::NoMatch,
+        _ => schema
+            .get_schema()
+            .user_attributes
+            .get_attribute_type(field)
+            .map(|(t, is_list)| UserFieldType::Attribute(field.clone(), t, is_list))
+            .unwrap_or(UserFieldType::NoMatch),
     }
 }
 
-pub fn map_group_field(field: &AttributeName) -> Option<&'static str> {
-    Some(match field.as_str() {
-        "cn" | "displayname" | "uid" | "display_name" => "display_name",
-        "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => "creation_date",
-        "entryuuid" | "uuid" => "uuid",
-        _ => return None,
-    })
+pub enum GroupFieldType {
+    NoMatch,
+    DisplayName,
+    CreationDate,
+    Uuid,
+    Attribute(AttributeName, AttributeType, bool),
+}
+
+pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFieldType {
+    match field.as_str() {
+        "cn" | "displayname" | "uid" | "display_name" => GroupFieldType::DisplayName,
+        "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
+            GroupFieldType::CreationDate
+        }
+        "entryuuid" | "uuid" => GroupFieldType::Uuid,
+        _ => schema
+            .get_schema()
+            .group_attributes
+            .get_attribute_type(field)
+            .map(|(t, is_list)| GroupFieldType::Attribute(field.clone(), t, is_list))
+            .unwrap_or(GroupFieldType::NoMatch),
+    }
 }
 
 pub struct LdapInfo {
diff --git a/server/src/domain/mod.rs b/server/src/domain/mod.rs
index c1bb5240..94752496 100644
--- a/server/src/domain/mod.rs
+++ b/server/src/domain/mod.rs
@@ -1,3 +1,4 @@
+pub mod deserialize;
 pub mod error;
 pub mod handler;
 pub mod ldap;
diff --git a/server/src/domain/sql_user_backend_handler.rs b/server/src/domain/sql_user_backend_handler.rs
index 02b3e699..d52418a6 100644
--- a/server/src/domain/sql_user_backend_handler.rs
+++ b/server/src/domain/sql_user_backend_handler.rs
@@ -22,14 +22,14 @@ use sea_orm::{
 use std::collections::HashSet;
 use tracing::instrument;
 
-fn attribute_condition(name: AttributeName, value: String) -> Cond {
+fn attribute_condition(name: AttributeName, value: Serialized) -> Cond {
     Expr::in_subquery(
         Expr::col(UserColumn::UserId.as_column_ref()),
         model::UserAttributes::find()
             .select_only()
             .column(model::UserAttributesColumn::UserId)
             .filter(model::UserAttributesColumn::AttributeName.eq(name))
-            .filter(model::UserAttributesColumn::Value.eq(Serialized::from(&value)))
+            .filter(model::UserAttributesColumn::Value.eq(value))
             .into_query(),
     )
     .into_condition()
@@ -463,7 +463,7 @@ mod tests {
             &fixture.handler,
             Some(UserRequestFilter::AttributeEquality(
                 AttributeName::from("first_name"),
-                "first bob".to_string(),
+                Serialized::from("first bob"),
             )),
         )
         .await;
diff --git a/server/src/infra/graphql/mutation.rs b/server/src/infra/graphql/mutation.rs
index 893e96b6..4d88613d 100644
--- a/server/src/infra/graphql/mutation.rs
+++ b/server/src/infra/graphql/mutation.rs
@@ -1,12 +1,13 @@
 use crate::{
     domain::{
+        deserialize::deserialize_attribute_value,
         handler::{
             AttributeList, BackendHandler, CreateAttributeRequest, CreateGroupRequest,
             CreateUserRequest, UpdateGroupRequest, UpdateUserRequest,
         },
         types::{
             AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
-            JpegPhoto, Serialized, UserId,
+            JpegPhoto, UserId,
         },
     },
     infra::{
@@ -535,54 +536,12 @@ fn deserialize_attribute(
         )
         .into());
     }
-    if !attribute_schema.is_list && attribute.value.len() != 1 {
-        return Err(anyhow!(
-            "Attribute {} is not a list, but multiple values were provided",
-            attribute.name
-        )
-        .into());
-    }
-    let parse_int = |value: &String| -> FieldResult<i64> {
-        Ok(value
-            .parse::<i64>()
-            .with_context(|| format!("Invalid integer value {}", value))?)
-    };
-    let parse_date = |value: &String| -> FieldResult<chrono::NaiveDateTime> {
-        Ok(chrono::DateTime::parse_from_rfc3339(value)
-            .with_context(|| format!("Invalid date value {}", value))?
-            .naive_utc())
-    };
-    let parse_photo = |value: &String| -> FieldResult<JpegPhoto> {
-        Ok(JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")?)
-    };
-    let deserialized_values = match (attribute_schema.attribute_type, attribute_schema.is_list) {
-        (AttributeType::String, false) => Serialized::from(&attribute.value[0]),
-        (AttributeType::String, true) => Serialized::from(&attribute.value),
-        (AttributeType::Integer, false) => Serialized::from(&parse_int(&attribute.value[0])?),
-        (AttributeType::Integer, true) => Serialized::from(
-            &attribute
-                .value
-                .iter()
-                .map(parse_int)
-                .collect::<FieldResult<Vec<_>>>()?,
-        ),
-        (AttributeType::DateTime, false) => Serialized::from(&parse_date(&attribute.value[0])?),
-        (AttributeType::DateTime, true) => Serialized::from(
-            &attribute
-                .value
-                .iter()
-                .map(parse_date)
-                .collect::<FieldResult<Vec<_>>>()?,
-        ),
-        (AttributeType::JpegPhoto, false) => Serialized::from(&parse_photo(&attribute.value[0])?),
-        (AttributeType::JpegPhoto, true) => Serialized::from(
-            &attribute
-                .value
-                .iter()
-                .map(parse_photo)
-                .collect::<FieldResult<Vec<_>>>()?,
-        ),
-    };
+    let deserialized_values = deserialize_attribute_value(
+        &attribute.value,
+        attribute_schema.attribute_type,
+        attribute_schema.is_list,
+    )
+    .context(format!("While deserializing attribute {}", attribute.name))?;
     Ok(DomainAttributeValue {
         name: attribute_name,
         value: deserialized_values,
diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs
index ce2c5c0e..df2da9c5 100644
--- a/server/src/infra/graphql/query.rs
+++ b/server/src/infra/graphql/query.rs
@@ -1,24 +1,25 @@
 use crate::{
     domain::{
+        deserialize::deserialize_attribute_value,
         handler::{BackendHandler, ReadSchemaBackendHandler},
         ldap::utils::{map_user_field, UserFieldType},
+        model::UserColumn,
         schema::{
             PublicSchema, SchemaAttributeExtractor, SchemaGroupAttributeExtractor,
             SchemaUserAttributeExtractor,
         },
-        types::{
-            AttributeName, AttributeType, GroupDetails, GroupId, JpegPhoto, UserColumn, UserId,
-        },
+        types::{AttributeType, GroupDetails, GroupId, JpegPhoto, UserId},
     },
     infra::{
         access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
         graphql::api::{field_error_callback, Context},
     },
 };
+use anyhow::Context as AnyhowContext;
 use chrono::{NaiveDateTime, TimeZone};
 use juniper::{graphql_object, FieldError, FieldResult, GraphQLInputObject};
 use serde::{Deserialize, Serialize};
-use tracing::{debug, debug_span, Instrument};
+use tracing::{debug, debug_span, Instrument, Span};
 
 type DomainRequestFilter = crate::domain::handler::UserRequestFilter;
 type DomainUser = crate::domain::types::User;
@@ -40,9 +41,8 @@ pub struct RequestFilter {
     member_of_id: Option<i32>,
 }
 
-impl TryInto<DomainRequestFilter> for RequestFilter {
-    type Error = String;
-    fn try_into(self) -> Result<DomainRequestFilter, Self::Error> {
+impl RequestFilter {
+    fn try_into_domain_filter(self, schema: &PublicSchema) -> FieldResult<DomainRequestFilter> {
         match (
             self.eq,
             self.any,
@@ -52,33 +52,39 @@ impl TryInto<DomainRequestFilter> for RequestFilter {
             self.member_of_id,
         ) {
             (Some(eq), None, None, None, None, None) => {
-                match map_user_field(&eq.field.as_str().into()) {
-                    UserFieldType::NoMatch => Err(format!("Unknown request filter: {}", &eq.field)),
+                match map_user_field(&eq.field.as_str().into(), schema) {
+                    UserFieldType::NoMatch => {
+                        Err(format!("Unknown request filter: {}", &eq.field).into())
+                    }
                     UserFieldType::PrimaryField(UserColumn::UserId) => {
                         Ok(DomainRequestFilter::UserId(UserId::new(&eq.value)))
                     }
                     UserFieldType::PrimaryField(column) => {
                         Ok(DomainRequestFilter::Equality(column, eq.value))
                     }
-                    UserFieldType::Attribute(column) => Ok(DomainRequestFilter::AttributeEquality(
-                        AttributeName::from(column),
-                        eq.value,
-                    )),
+                    UserFieldType::Attribute(name, typ, false) => {
+                        let value = deserialize_attribute_value(&[eq.value], typ, false)
+                            .context(format!("While deserializing attribute {}", &name))?;
+                        Ok(DomainRequestFilter::AttributeEquality(name, value))
+                    }
+                    UserFieldType::Attribute(_, _, true) => {
+                        Err("Equality not supported for list fields".into())
+                    }
                 }
             }
             (None, Some(any), None, None, None, None) => Ok(DomainRequestFilter::Or(
                 any.into_iter()
-                    .map(TryInto::try_into)
-                    .collect::<Result<Vec<_>, String>>()?,
+                    .map(|f| f.try_into_domain_filter(schema))
+                    .collect::<FieldResult<Vec<_>>>()?,
             )),
             (None, None, Some(all), None, None, None) => Ok(DomainRequestFilter::And(
                 all.into_iter()
-                    .map(TryInto::try_into)
-                    .collect::<Result<Vec<_>, String>>()?,
+                    .map(|f| f.try_into_domain_filter(schema))
+                    .collect::<FieldResult<Vec<_>>>()?,
             )),
-            (None, None, None, Some(not), None, None) => {
-                Ok(DomainRequestFilter::Not(Box::new((*not).try_into()?)))
-            }
+            (None, None, None, Some(not), None, None) => Ok(DomainRequestFilter::Not(Box::new(
+                (*not).try_into_domain_filter(schema)?,
+            ))),
             (None, None, None, None, Some(group), None) => {
                 Ok(DomainRequestFilter::MemberOf(group.into()))
             }
@@ -86,9 +92,9 @@ impl TryInto<DomainRequestFilter> for RequestFilter {
                 Ok(DomainRequestFilter::MemberOfId(GroupId(group_id)))
             }
             (None, None, None, None, None, None) => {
-                Err("No field specified in request filter".to_string())
+                Err("No field specified in request filter".into())
             }
-            _ => Err("Multiple fields specified in request filter".to_string()),
+            _ => Err("Multiple fields specified in request filter".into()),
         }
     }
 }
@@ -154,8 +160,14 @@ impl<Handler: BackendHandler> Query<Handler> {
                 &span,
                 "Unauthorized access to user list",
             ))?;
+        let schema = self.get_schema(context, span.clone()).await?;
         Ok(handler
-            .list_users(filters.map(TryInto::try_into).transpose()?, false)
+            .list_users(
+                filters
+                    .map(|f| f.try_into_domain_filter(&schema))
+                    .transpose()?,
+                false,
+            )
             .instrument(span)
             .await
             .map(|v| v.into_iter().map(Into::into).collect())?)
@@ -196,6 +208,16 @@ impl<Handler: BackendHandler> Query<Handler> {
 
     async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
         let span = debug_span!("[GraphQL query] get_schema");
+        self.get_schema(context, span).await.map(Into::into)
+    }
+}
+
+impl<Handler: BackendHandler> Query<Handler> {
+    async fn get_schema(
+        &self,
+        context: &Context<Handler>,
+        span: Span,
+    ) -> FieldResult<PublicSchema> {
         let handler = context
             .handler
             .get_user_restricted_lister_handler(&context.validation_result);
@@ -203,8 +225,7 @@ impl<Handler: BackendHandler> Query<Handler> {
             .get_schema()
             .instrument(span)
             .await
-            .map(Into::<PublicSchema>::into)
-            .map(Into::into)?)
+            .map(Into::<PublicSchema>::into)?)
     }
 }
 
@@ -594,7 +615,7 @@ mod tests {
     use crate::{
         domain::{
             handler::AttributeList,
-            types::{AttributeType, Serialized},
+            types::{AttributeName, AttributeType, Serialized},
         },
         infra::{
             access_control::{Permission, ValidationResults},
@@ -795,6 +816,7 @@ mod tests {
         }"#;
 
         let mut mock = MockTestBackendHandler::new();
+        setup_default_schema(&mut mock);
         mock.expect_list_users()
             .with(
                 eq(Some(DomainRequestFilter::Or(vec![
@@ -805,7 +827,7 @@ mod tests {
                     ),
                     DomainRequestFilter::AttributeEquality(
                         AttributeName::from("first_name"),
-                        "robert".to_owned(),
+                        Serialized::from("robert"),
                     ),
                 ]))),
                 eq(false),
diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs
index 902c4f73..02b901c9 100644
--- a/server/src/infra/ldap_handler.rs
+++ b/server/src/infra/ldap_handler.rs
@@ -531,6 +531,7 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
         &self,
         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(&self.ldap_info.base_dn, &dn_parts, &request.scope);
@@ -554,11 +555,19 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
                 need_groups,
                 &request.base,
                 backend_handler,
+                schema,
             )
             .await
         });
         let get_group_list = cast(|filter: &LdapFilter| async {
-            get_groups_list(&self.ldap_info, filter, &request.base, backend_handler).await
+            get_groups_list(
+                &self.ldap_info,
+                filter,
+                &request.base,
+                backend_handler,
+                schema,
+            )
+            .await
         });
         Ok(match scope {
             SearchScope::Global => InternalSearchResults::UsersAndGroups(
@@ -617,13 +626,15 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
         let backend_handler = self
             .backend_handler
             .get_user_restricted_lister_handler(user_info);
-        let search_results = self.do_search_internal(&backend_handler, request).await?;
 
         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 = self
+            .do_search_internal(&backend_handler, request, &schema)
+            .await?;
         let mut results = match search_results {
             InternalSearchResults::UsersAndGroups(users, groups) => {
                 convert_users_to_ldap_op(users, &request.attrs, &self.ldap_info, &schema)
@@ -1686,7 +1697,7 @@ mod tests {
                         false.into(),
                         UserRequestFilter::AttributeEquality(
                             AttributeName::from("first_name"),
-                            "firstname".to_owned(),
+                            Serialized::from("firstname"),
                         ),
                         false.into(),
                         UserRequestFilter::UserIdSubString(SubStringFilter {


Test patch: