From 81036943c22d49c26e0fd3d4aceefe3e727ef8c2 Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Mon, 13 Feb 2023 15:41:07 +0100 Subject: [PATCH] server: Add support for SubString ldap filter --- server/src/domain/handler.rs | 34 ++++++++ server/src/domain/ldap/group.rs | 15 ++++ server/src/domain/ldap/user.rs | 22 +++++ server/src/domain/ldap/utils.rs | 23 +++++- .../src/domain/sql_group_backend_handler.rs | 29 ++++++- server/src/domain/sql_user_backend_handler.rs | 35 +++++++- server/src/infra/ldap_handler.rs | 80 ++++++++++++++++--- 7 files changed, 219 insertions(+), 19 deletions(-) diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs index d93657d..1b2cb4f 100644 --- a/server/src/domain/handler.rs +++ b/server/src/domain/handler.rs @@ -14,13 +14,46 @@ pub struct BindRequest { pub password: String, } +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] +pub struct SubStringFilter { + pub initial: Option, + pub any: Vec, + pub final_: Option, +} + +impl SubStringFilter { + pub fn to_sql_filter(&self) -> String { + let mut filter = String::with_capacity( + self.initial.as_ref().map(String::len).unwrap_or_default() + + 1 + + self.any.iter().map(String::len).sum::() + + self.any.len() + + self.final_.as_ref().map(String::len).unwrap_or_default(), + ); + if let Some(f) = &self.initial { + filter.push_str(&f.to_ascii_lowercase()); + } + filter.push('%'); + for part in self.any.iter() { + filter.push_str(&part.to_ascii_lowercase()); + filter.push('%'); + } + if let Some(f) = &self.final_ { + filter.push_str(&f.to_ascii_lowercase()); + } + filter + } +} + #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] pub enum UserRequestFilter { And(Vec), Or(Vec), Not(Box), UserId(UserId), + UserIdSubString(SubStringFilter), Equality(UserColumn, String), + SubString(UserColumn, SubStringFilter), // Check if a user belongs to a group identified by name. MemberOf(String), // Same, by id. @@ -43,6 +76,7 @@ pub enum GroupRequestFilter { Or(Vec), Not(Box), DisplayName(String), + DisplayNameSubString(SubStringFilter), Uuid(Uuid), GroupId(GroupId), // Check if the group contains a user identified by uid. diff --git a/server/src/domain/ldap/group.rs b/server/src/domain/ldap/group.rs index a2cd2a9..00bde72 100644 --- a/server/src/domain/ldap/group.rs +++ b/server/src/domain/ldap/group.rs @@ -178,6 +178,21 @@ fn convert_group_filter( || map_group_field(field).is_some(), )) } + LdapFilter::Substring(field, substring_filter) => { + let field = &field.to_ascii_lowercase(); + match map_group_field(field.as_str()) { + Some(GroupColumn::DisplayName) => Ok(GroupRequestFilter::DisplayNameSubString( + substring_filter.clone().into(), + )), + _ => Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: format!( + "Unsupported group attribute for substring filter: {:?}", + field + ), + }), + } + } _ => Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: format!("Unsupported group filter: {:?}", filter), diff --git a/server/src/domain/ldap/user.rs b/server/src/domain/ldap/user.rs index ab27a07..d84a065 100644 --- a/server/src/domain/ldap/user.rs +++ b/server/src/domain/ldap/user.rs @@ -185,6 +185,28 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult< || map_user_field(field).is_some(), )) } + LdapFilter::Substring(field, substring_filter) => { + let field = &field.to_ascii_lowercase(); + match map_user_field(field.as_str()) { + Some(UserColumn::UserId) => Ok(UserRequestFilter::UserIdSubString( + substring_filter.clone().into(), + )), + None + | Some(UserColumn::CreationDate) + | Some(UserColumn::Avatar) + | Some(UserColumn::Uuid) => Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: format!( + "Unsupported user attribute for substring filter: {:?}", + field + ), + }), + Some(field) => Ok(UserRequestFilter::SubString( + field, + substring_filter.clone().into(), + )), + } + } _ => Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: format!("Unsupported user filter: {:?}", filter), diff --git a/server/src/domain/ldap/utils.rs b/server/src/domain/ldap/utils.rs index d84c430..852d6e6 100644 --- a/server/src/domain/ldap/utils.rs +++ b/server/src/domain/ldap/utils.rs @@ -1,12 +1,29 @@ use itertools::Itertools; -use ldap3_proto::LdapResultCode; +use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode}; use tracing::{debug, instrument, warn}; use crate::domain::{ + handler::SubStringFilter, ldap::error::{LdapError, LdapResult}, types::{GroupColumn, UserColumn, UserId}, }; +impl From for SubStringFilter { + fn from( + LdapSubstringFilter { + initial, + any, + final_, + }: LdapSubstringFilter, + ) -> Self { + Self { + initial, + any, + final_, + } + } +} + fn make_dn_pair(mut iter: I) -> LdapResult<(String, String)> where I: Iterator, @@ -141,8 +158,8 @@ pub fn map_user_field(field: &str) -> Option { "uid" | "user_id" | "id" => UserColumn::UserId, "mail" | "email" => UserColumn::Email, "cn" | "displayname" | "display_name" => UserColumn::DisplayName, - "givenname" | "first_name" => UserColumn::FirstName, - "sn" | "last_name" => UserColumn::LastName, + "givenname" | "first_name" | "firstname" => UserColumn::FirstName, + "sn" | "last_name" | "lastname" => UserColumn::LastName, "avatar" | "jpegphoto" => UserColumn::Avatar, "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => { UserColumn::CreationDate diff --git a/server/src/domain/sql_group_backend_handler.rs b/server/src/domain/sql_group_backend_handler.rs index e5677a4..afffb5c 100644 --- a/server/src/domain/sql_group_backend_handler.rs +++ b/server/src/domain/sql_group_backend_handler.rs @@ -7,7 +7,7 @@ use crate::domain::{ }; use async_trait::async_trait; use sea_orm::{ - sea_query::{Cond, IntoCondition, SimpleExpr}, + sea_query::{Alias, Cond, Expr, Func, IntoCondition, SimpleExpr}, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, }; @@ -15,6 +15,7 @@ use tracing::{debug, instrument}; fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { use GroupRequestFilter::*; + let group_table = Alias::new("groups"); match filter { And(fs) => { if fs.is_empty() { @@ -46,6 +47,12 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { .into_query(), ) .into_condition(), + DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col(( + group_table, + GroupColumn::DisplayName, + )))) + .like(filter.to_sql_filter()) + .into_condition(), } } @@ -146,7 +153,7 @@ impl GroupBackendHandler for SqlBackendHandler { #[cfg(test)] mod tests { use super::*; - use crate::domain::{sql_backend_handler::tests::*, types::UserId}; + use crate::domain::{handler::SubStringFilter, sql_backend_handler::tests::*, types::UserId}; async fn get_group_ids( handler: &SqlBackendHandler, @@ -221,6 +228,24 @@ mod tests { ); } + #[tokio::test] + async fn test_list_groups_substring_filter() { + let fixture = TestFixture::new().await; + assert_eq!( + get_group_ids( + &fixture.handler, + Some(GroupRequestFilter::DisplayNameSubString(SubStringFilter { + initial: Some("be".to_owned()), + any: vec!["sT".to_owned()], + final_: Some("P".to_owned()), + })), + ) + .await, + // Best group + vec![fixture.groups[0]] + ); + } + #[tokio::test] async fn test_get_group_details() { let fixture = TestFixture::new().await; diff --git a/server/src/domain/sql_user_backend_handler.rs b/server/src/domain/sql_user_backend_handler.rs index a481565..529c3e9 100644 --- a/server/src/domain/sql_user_backend_handler.rs +++ b/server/src/domain/sql_user_backend_handler.rs @@ -8,7 +8,7 @@ use super::{ use async_trait::async_trait; use sea_orm::{ entity::IntoActiveValue, - sea_query::{Alias, Cond, Expr, IntoColumnRef, IntoCondition, SimpleExpr}, + sea_query::{Alias, Cond, Expr, Func, IntoColumnRef, IntoCondition, SimpleExpr}, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, Set, }; @@ -49,8 +49,15 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond { MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId)) .eq(group_id) .into_condition(), + UserIdSubString(filter) => UserColumn::UserId + .like(&filter.to_sql_filter()) + .into_condition(), + SubString(col, filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col(col))) + .like(filter.to_sql_filter()) + .into_condition(), } } + fn to_value(opt_name: &Option) -> ActiveValue> { match opt_name { None => ActiveValue::NotSet, @@ -236,6 +243,7 @@ impl UserBackendHandler for SqlBackendHandler { mod tests { use super::*; use crate::domain::{ + handler::SubStringFilter, sql_backend_handler::tests::*, types::{JpegPhoto, UserColumn}, }; @@ -286,6 +294,31 @@ mod tests { assert_eq!(users, vec!["bob"]); } + #[tokio::test] + async fn test_list_users_substring_filter() { + let fixture = TestFixture::new().await; + let users = get_user_names( + &fixture.handler, + Some(UserRequestFilter::And(vec![ + UserRequestFilter::UserIdSubString(SubStringFilter { + initial: Some("Pa".to_owned()), + any: vec!["rI".to_owned()], + final_: Some("K".to_owned()), + }), + UserRequestFilter::SubString( + UserColumn::FirstName, + SubStringFilter { + initial: None, + any: vec!["r".to_owned(), "t".to_owned()], + final_: None, + }, + ), + ])), + ) + .await; + assert_eq!(users, vec!["patrick"]); + } + #[tokio::test] async fn test_list_users_false_filter() { let fixture = TestFixture::new().await; diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 3527900..b6959d3 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -680,7 +680,7 @@ mod tests { }; use async_trait::async_trait; use chrono::TimeZone; - use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope}; + use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope, LdapSubstringFilter}; use mockall::predicate::eq; use std::collections::HashSet; use tokio; @@ -1322,6 +1322,11 @@ mod tests { true.into(), GroupRequestFilter::Not(Box::new(false.into())), false.into(), + GroupRequestFilter::DisplayNameSubString(SubStringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }), ])))) .times(1) .return_once(|_| { @@ -1357,6 +1362,14 @@ mod tests { "random_attribUte".to_string(), ))), LdapFilter::Equality("unknown_attribute".to_string(), "randomValue".to_string()), + LdapFilter::Substring( + "cn".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), ]), vec!["1.1"], ); @@ -1442,6 +1455,22 @@ mod tests { ); } + #[tokio::test] + async fn test_search_groups_unsupported_substring() { + let mut ldap_handler = setup_bound_admin_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_error() { let mut mock = MockTestBackendHandler::new(); @@ -1478,18 +1507,17 @@ mod tests { async fn test_search_groups_filter_error() { let mut ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_group_search_request( - LdapFilter::And(vec![LdapFilter::Substring( - "whatever".to_string(), - ldap3_proto::proto::LdapSubstringFilter::default(), + 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{ + Err(LdapError { code: LdapResultCode::UnwillingToPerform, - message: r#"Unsupported group filter: Substring("whatever", LdapSubstringFilter { initial: None, any: [], final_: None })"# - .to_string() + message: r#"Unsupported group filter: Approx("whatever", "value")"#.to_string() }) ); } @@ -1512,6 +1540,19 @@ mod tests { true.into(), false.into(), false.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::FirstName, + SubStringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), ], )]))), eq(false), @@ -1539,6 +1580,22 @@ mod tests { LdapFilter::Present("uid".to_string()), LdapFilter::Present("unknown".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( + "firstName".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), ])]), vec!["objectClass"], ); @@ -1910,17 +1967,14 @@ mod tests { async fn test_search_unsupported_filters() { let mut ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; let request = make_user_search_request( - LdapFilter::Substring( - "uid".to_string(), - ldap3_proto::proto::LdapSubstringFilter::default(), - ), + LdapFilter::Approx("uid".to_owned(), "value".to_owned()), vec!["objectClass"], ); assert_eq!( ldap_handler.do_search_or_dse(&request).await, - Err(LdapError{ + Err(LdapError { code: LdapResultCode::UnwillingToPerform, - message: r#"Unsupported user filter: Substring("uid", LdapSubstringFilter { initial: None, any: [], final_: None })"#.to_string() + message: r#"Unsupported user filter: Approx("uid", "value")"#.to_string() }) ); }