diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs index 0b4550c..f218f1d 100644 --- a/server/src/domain/handler.rs +++ b/server/src/domain/handler.rs @@ -83,6 +83,7 @@ pub enum GroupRequestFilter { GroupId(GroupId), // Check if the group contains a user identified by uid. Member(UserId), + AttributeEquality(AttributeName, Serialized), } impl From for GroupRequestFilter { diff --git a/server/src/domain/ldap/group.rs b/server/src/domain/ldap/group.rs index c769e6c..d59a78d 100644 --- a/server/src/domain/ldap/group.rs +++ b/server/src/domain/ldap/group.rs @@ -1,13 +1,15 @@ +use chrono::TimeZone; use ldap3_proto::{ proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, }; use tracing::{debug, instrument, warn}; use crate::domain::{ + deserialize::deserialize_attribute_value, handler::{GroupListerBackendHandler, GroupRequestFilter}, ldap::error::LdapError, schema::{PublicSchema, SchemaGroupAttributeExtractor}, - types::{AttributeName, Group, UserId, Uuid}, + types::{AttributeName, AttributeType, Group, UserId, Uuid}, }; use super::{ @@ -27,47 +29,53 @@ pub fn get_group_attribute( schema: &PublicSchema, ) -> Option>> { let attribute = AttributeName::from(attribute); - let attribute_values = match attribute.as_str() { - "objectclass" => vec![b"groupOfUniqueNames".to_vec()], + let attribute_values = match map_group_field(&attribute, schema) { + GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()], // Always returned as part of the base response. - "dn" | "distinguishedname" => return None, - "entrydn" => { + GroupFieldType::Dn => return None, + GroupFieldType::EntryDn => { vec![format!("uid={},ou=groups,{}", group.display_name, base_dn_str).into_bytes()] } - "cn" | "uid" | "id" => vec![group.display_name.to_string().into_bytes()], - "entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()], - "member" | "uniquemember" => group + GroupFieldType::DisplayName => vec![group.display_name.to_string().into_bytes()], + GroupFieldType::CreationDate => vec![chrono::Utc + .from_utc_datetime(&group.creation_date) + .to_rfc3339() + .into_bytes()], + GroupFieldType::Member => group .users .iter() .filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true)) .map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes()) .collect(), - "1.1" => return None, - // We ignore the operational attribute wildcard - "+" => return None, - "*" => { - panic!( - "Matched {}, * should have been expanded into attribute list and * removed", - attribute - ) + GroupFieldType::Uuid => vec![group.uuid.to_string().into_bytes()], + GroupFieldType::Attribute(attr, _, _) => { + get_custom_attribute::(&group.attributes, &attr, schema)? } - _ => { - if !ignored_group_attributes.contains(&attribute) { - match get_custom_attribute::( - &group.attributes, - &attribute, - schema, - ) { - Some(v) => return Some(v), - None => warn!( - r#"Ignoring unrecognized group attribute: {}\n\ - To disable this warning, add it to "ignored_group_attributes" in the config."#, - attribute - ), - }; + GroupFieldType::NoMatch => match attribute.as_str() { + "1.1" => return None, + // We ignore the operational attribute wildcard + "+" => return None, + "*" => { + panic!( + "Matched {}, * should have been expanded into attribute list and * removed", + attribute + ) } - return None; - } + _ => { + if ignored_group_attributes.contains(&attribute) { + return None; + } + get_custom_attribute::( + &group.attributes, + &attribute, + schema, + ).or_else(||{warn!( + r#"Ignoring unrecognized group attribute: {}\n\ + To disable this warning, add it to "ignored_group_attributes" in the config."#, + attribute + );None})? + } + }, }; if attribute_values.len() == 1 && attribute_values[0].is_empty() { None @@ -121,6 +129,20 @@ fn make_ldap_search_group_result_entry( } } +fn get_group_attribute_equality_filter( + field: &AttributeName, + typ: AttributeType, + is_list: bool, + value: &str, +) -> LdapResult { + 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| GroupRequestFilter::AttributeEquality(field.clone(), v)) +} + fn convert_group_filter( ldap_info: &LdapInfo, filter: &LdapFilter, @@ -131,8 +153,15 @@ fn convert_group_filter( LdapFilter::Equality(field, value) => { let field = AttributeName::from(field.as_str()); let value = value.to_ascii_lowercase(); - match field.as_str() { - "member" | "uniquemember" => { + 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), + })?, + )), + GroupFieldType::Member => { let user_name = get_user_id_from_distinguished_name( &value, &ldap_info.base_dn, @@ -140,41 +169,39 @@ fn convert_group_filter( )?; Ok(GroupRequestFilter::Member(user_name)) } - "objectclass" => Ok(GroupRequestFilter::from(matches!( + GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(matches!( value.as_str(), "groupofuniquenames" | "groupofnames" ))), - "dn" => Ok(get_group_id_from_distinguished_name( - value.to_ascii_lowercase().as_str(), - &ldap_info.base_dn, - &ldap_info.base_dn_str, - ) - .map(GroupRequestFilter::DisplayName) - .unwrap_or_else(|_| { - warn!("Invalid dn filter on group: {}", value); - GroupRequestFilter::from(false) - })), - _ => 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), - })?, - )), - _ => { - if !ldap_info.ignored_group_attributes.contains(&field) { - warn!( - r#"Ignoring unknown group attribute "{}" in filter.\n\ + GroupFieldType::Dn | GroupFieldType::EntryDn => { + Ok(get_group_id_from_distinguished_name( + value.as_str(), + &ldap_info.base_dn, + &ldap_info.base_dn_str, + ) + .map(GroupRequestFilter::DisplayName) + .unwrap_or_else(|_| { + warn!("Invalid dn filter on group: {}", value); + GroupRequestFilter::from(false) + })) + } + GroupFieldType::NoMatch => { + if !ldap_info.ignored_group_attributes.contains(&field) { + warn!( + r#"Ignoring unknown group attribute "{}" in filter.\n\ To disable this warning, add it to "ignored_group_attributes" in the config."#, - field - ); - } - Ok(GroupRequestFilter::from(false)) + field + ); } - }, + Ok(GroupRequestFilter::from(false)) + } + GroupFieldType::Attribute(field, typ, is_list) => { + get_group_attribute_equality_filter(&field, typ, is_list, &value) + } + GroupFieldType::CreationDate => Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: "Creation date filter for groups not supported".to_owned(), + }), } } LdapFilter::And(filters) => Ok(GroupRequestFilter::And( @@ -186,12 +213,10 @@ fn convert_group_filter( LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))), LdapFilter::Present(field) => { let field = AttributeName::from(field.as_str()); - Ok(GroupRequestFilter::from( - field.as_str() == "objectclass" - || field.as_str() == "dn" - || field.as_str() == "distinguishedname" - || !matches!(map_group_field(&field, schema), GroupFieldType::NoMatch), - )) + Ok(GroupRequestFilter::from(!matches!( + map_group_field(&field, schema), + GroupFieldType::NoMatch + ))) } LdapFilter::Substring(field, substring_filter) => { let field = AttributeName::from(field.as_str()); @@ -199,6 +224,7 @@ fn convert_group_filter( GroupFieldType::DisplayName => Ok(GroupRequestFilter::DisplayNameSubString( substring_filter.clone().into(), )), + GroupFieldType::NoMatch => Ok(GroupRequestFilter::from(false)), _ => Err(LdapError { code: LdapResultCode::UnwillingToPerform, message: format!( diff --git a/server/src/domain/ldap/user.rs b/server/src/domain/ldap/user.rs index 23b92a1..f6b2fe8 100644 --- a/server/src/domain/ldap/user.rs +++ b/server/src/domain/ldap/user.rs @@ -27,78 +27,75 @@ pub fn get_user_attribute( schema: &PublicSchema, ) -> Option>> { let attribute = AttributeName::from(attribute); - let attribute_values = match attribute.as_str() { - "objectclass" => vec![ + let attribute_values = match map_user_field(&attribute, schema) { + UserFieldType::ObjectClass => vec![ b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), b"person".to_vec(), ], // dn is always returned as part of the base response. - "dn" | "distinguishedname" => return None, - "entrydn" => { + UserFieldType::Dn => return None, + UserFieldType::EntryDn => { vec![format!("uid={},ou=people,{}", &user.user_id, base_dn_str).into_bytes()] } - "uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()], - "entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()], - "mail" | "email" => vec![user.email.to_string().into_bytes()], - "givenname" | "first_name" | "firstname" => { - get_custom_attribute::( - &user.attributes, - &"first_name".into(), - schema, - )? - } - "sn" | "last_name" | "lastname" => get_custom_attribute::( - &user.attributes, - &"last_name".into(), - schema, - )?, - "jpegphoto" | "avatar" => get_custom_attribute::( - &user.attributes, - &"avatar".into(), - schema, - )?, - "memberof" => groups + UserFieldType::MemberOf => groups .into_iter() .flatten() .map(|id_and_name| { format!("cn={},ou=groups,{}", &id_and_name.display_name, base_dn_str).into_bytes() }) .collect(), - "cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()], - "creationdate" | "creation_date" | "createtimestamp" | "modifytimestamp" => { - vec![chrono::Utc - .from_utc_datetime(&user.creation_date) - .to_rfc3339() - .into_bytes()] + UserFieldType::PrimaryField(UserColumn::UserId) => { + vec![user.user_id.to_string().into_bytes()] } - "1.1" => return None, - // We ignore the operational attribute wildcard. - "+" => return None, - "*" => { - panic!( - "Matched {}, * should have been expanded into attribute list and * removed", - attribute - ) + UserFieldType::PrimaryField(UserColumn::Email) => vec![user.email.to_string().into_bytes()], + UserFieldType::PrimaryField( + UserColumn::LowercaseEmail + | UserColumn::PasswordHash + | UserColumn::TotpSecret + | UserColumn::MfaType, + ) => panic!("Should not get here"), + UserFieldType::PrimaryField(UserColumn::Uuid) => vec![user.uuid.to_string().into_bytes()], + UserFieldType::PrimaryField(UserColumn::DisplayName) => { + vec![user.display_name.clone()?.into_bytes()] } - attr => { - if !ignored_user_attributes.contains(&attribute) { - match get_custom_attribute::( + UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![chrono::Utc + .from_utc_datetime(&user.creation_date) + .to_rfc3339() + .into_bytes()], + UserFieldType::Attribute(attr, _, _) => { + get_custom_attribute::(&user.attributes, &attr, schema)? + } + UserFieldType::NoMatch => match attribute.as_str() { + "1.1" => return None, + // We ignore the operational attribute wildcard. + "+" => return None, + "*" => { + panic!( + "Matched {}, * should have been expanded into attribute list and * removed", + attribute + ) + } + _ => { + if ignored_user_attributes.contains(&attribute) { + return None; + } + get_custom_attribute::( &user.attributes, &attribute, schema, - ) { - Some(v) => return Some(v), - None => warn!( + ) + .or_else(|| { + warn!( r#"Ignoring unrecognized group attribute: {}\n\ To disable this warning, add it to "ignored_user_attributes" in the config."#, - attr - ), - }; + attribute + ); + None + })? } - return None; - } + }, }; if attribute_values.len() == 1 && attribute_values[0].is_empty() { None @@ -181,49 +178,48 @@ fn convert_user_filter( LdapFilter::Not(filter) => Ok(UserRequestFilter::Not(Box::new(rec(filter)?))), LdapFilter::Equality(field, value) => { let field = AttributeName::from(field.as_str()); - match field.as_str() { - "memberof" => Ok(UserRequestFilter::MemberOf( + let value = value.to_ascii_lowercase(); + match map_user_field(&field, schema) { + UserFieldType::PrimaryField(UserColumn::UserId) => { + Ok(UserRequestFilter::UserId(UserId::new(&value))) + } + UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::Equality(field, value)), + 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!( + r#"Ignoring unknown user attribute "{}" in filter.\n\ + To disable this warning, add it to "ignored_user_attributes" in the config"#, + field + ); + } + Ok(UserRequestFilter::from(false)) + } + UserFieldType::ObjectClass => Ok(UserRequestFilter::from(matches!( + value.as_str(), + "person" | "inetorgperson" | "posixaccount" | "mailaccount" + ))), + UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf( get_group_id_from_distinguished_name( - &value.to_ascii_lowercase(), + &value, &ldap_info.base_dn, &ldap_info.base_dn_str, )?, )), - "objectclass" => Ok(UserRequestFilter::from(matches!( - value.to_ascii_lowercase().as_str(), - "person" | "inetorgperson" | "posixaccount" | "mailaccount" - ))), - "dn" => Ok(get_user_id_from_distinguished_name( - value.to_ascii_lowercase().as_str(), - &ldap_info.base_dn, - &ldap_info.base_dn_str, - ) - .map(UserRequestFilter::UserId) - .unwrap_or_else(|_| { - warn!("Invalid dn filter on user: {}", value); - UserRequestFilter::from(false) - })), - _ => 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, typ, is_list) => { - get_user_attribute_equality_filter(&field, typ, is_list, value) - } - UserFieldType::NoMatch => { - if !ldap_info.ignored_user_attributes.contains(&field) { - warn!( - r#"Ignoring unknown user attribute "{}" in filter.\n\ - To disable this warning, add it to "ignored_user_attributes" in the config"#, - field - ); - } - Ok(UserRequestFilter::from(false)) - } - }, + UserFieldType::EntryDn | UserFieldType::Dn => { + Ok(get_user_id_from_distinguished_name( + value.as_str(), + &ldap_info.base_dn, + &ldap_info.base_dn_str, + ) + .map(UserRequestFilter::UserId) + .unwrap_or_else(|_| { + warn!("Invalid dn filter on user: {}", value); + UserRequestFilter::from(false) + })) + } } } LdapFilter::Present(field) => { @@ -242,8 +238,11 @@ fn convert_user_filter( UserFieldType::PrimaryField(UserColumn::UserId) => Ok( UserRequestFilter::UserIdSubString(substring_filter.clone().into()), ), - UserFieldType::NoMatch - | UserFieldType::Attribute(_, _, _) + UserFieldType::Attribute(_, _, _) + | UserFieldType::ObjectClass + | UserFieldType::MemberOf + | UserFieldType::Dn + | UserFieldType::EntryDn | UserFieldType::PrimaryField(UserColumn::CreationDate) | UserFieldType::PrimaryField(UserColumn::Uuid) => Err(LdapError { code: LdapResultCode::UnwillingToPerform, @@ -252,6 +251,7 @@ fn convert_user_filter( field ), }), + UserFieldType::NoMatch => Ok(UserRequestFilter::from(false)), UserFieldType::PrimaryField(field) => Ok(UserRequestFilter::SubString( field, substring_filter.clone().into(), diff --git a/server/src/domain/ldap/utils.rs b/server/src/domain/ldap/utils.rs index ad61225..a1696ac 100644 --- a/server/src/domain/ldap/utils.rs +++ b/server/src/domain/ldap/utils.rs @@ -158,12 +158,20 @@ pub fn is_subtree(subtree: &[(String, String)], base_tree: &[(String, String)]) pub enum UserFieldType { NoMatch, + ObjectClass, + MemberOf, + Dn, + EntryDn, PrimaryField(UserColumn), Attribute(AttributeName, AttributeType, bool), } pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserFieldType { match field.as_str() { + "memberof" | "ismemberof" => UserFieldType::MemberOf, + "objectclass" => UserFieldType::ObjectClass, + "dn" | "distinguishedname" => UserFieldType::Dn, + "entrydn" => UserFieldType::EntryDn, "uid" | "user_id" | "id" => UserFieldType::PrimaryField(UserColumn::UserId), "mail" | "email" => UserFieldType::PrimaryField(UserColumn::Email), "cn" | "displayname" | "display_name" => { @@ -201,16 +209,25 @@ pub enum GroupFieldType { NoMatch, DisplayName, CreationDate, + ObjectClass, + Dn, + // Like Dn, but returned as part of the attributes. + EntryDn, + Member, 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, + "dn" | "distinguishedname" => GroupFieldType::Dn, + "entrydn" => GroupFieldType::EntryDn, + "objectclass" => GroupFieldType::ObjectClass, + "cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName, "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => { GroupFieldType::CreationDate } + "member" | "uniquemember" => GroupFieldType::Member, "entryuuid" | "uuid" => GroupFieldType::Uuid, _ => schema .get_schema() diff --git a/server/src/domain/sql_group_backend_handler.rs b/server/src/domain/sql_group_backend_handler.rs index e768cec..8facaae 100644 --- a/server/src/domain/sql_group_backend_handler.rs +++ b/server/src/domain/sql_group_backend_handler.rs @@ -6,7 +6,7 @@ use crate::domain::{ }, model::{self, GroupColumn, MembershipColumn}, sql_backend_handler::SqlBackendHandler, - types::{AttributeValue, Group, GroupDetails, GroupId, Uuid}, + types::{AttributeName, AttributeValue, Group, GroupDetails, GroupId, Serialized, Uuid}, }; use async_trait::async_trait; use sea_orm::{ @@ -16,6 +16,19 @@ use sea_orm::{ }; use tracing::instrument; +fn attribute_condition(name: AttributeName, value: Serialized) -> Cond { + Expr::in_subquery( + Expr::col(GroupColumn::GroupId.as_column_ref()), + model::GroupAttributes::find() + .select_only() + .column(model::GroupAttributesColumn::GroupId) + .filter(model::GroupAttributesColumn::AttributeName.eq(name)) + .filter(model::GroupAttributesColumn::Value.eq(value)) + .into_query(), + ) + .into_condition() +} + fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { use GroupRequestFilter::*; let group_table = Alias::new("groups"); @@ -58,6 +71,7 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { )))) .like(filter.to_sql_filter()) .into_condition(), + AttributeEquality(name, value) => attribute_condition(name, value), } } @@ -405,6 +419,46 @@ mod tests { ); } + #[tokio::test] + async fn test_list_groups_other_filter() { + let fixture = TestFixture::new().await; + fixture + .handler + .add_group_attribute(CreateAttributeRequest { + name: "gid".into(), + attribute_type: AttributeType::Integer, + is_list: false, + is_visible: true, + is_editable: true, + }) + .await + .unwrap(); + fixture + .handler + .update_group(UpdateGroupRequest { + group_id: fixture.groups[0], + display_name: None, + delete_attributes: Vec::new(), + insert_attributes: vec![AttributeValue { + name: "gid".into(), + value: Serialized::from(&512), + }], + }) + .await + .unwrap(); + assert_eq!( + get_group_ids( + &fixture.handler, + Some(GroupRequestFilter::AttributeEquality( + AttributeName::from("gid"), + Serialized::from(&512), + )), + ) + .await, + vec![fixture.groups[0]] + ); + } + #[tokio::test] async fn test_get_group_details() { let fixture = TestFixture::new().await; diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs index df2da9c..de6bffc 100644 --- a/server/src/infra/graphql/query.rs +++ b/server/src/infra/graphql/query.rs @@ -70,6 +70,10 @@ impl RequestFilter { UserFieldType::Attribute(_, _, true) => { Err("Equality not supported for list fields".into()) } + UserFieldType::MemberOf => Ok(DomainRequestFilter::MemberOf(eq.value.into())), + UserFieldType::ObjectClass | UserFieldType::Dn | UserFieldType::EntryDn => { + Err("Ldap fields not supported in request filter".into()) + } } } (None, Some(any), None, None, None, None) => Ok(DomainRequestFilter::Or( diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 2b05e23..27d8ca7 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -1613,7 +1613,7 @@ mod tests { #[tokio::test] async fn test_search_groups_unsupported_substring() { - let mut ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let mut ldap_handler = setup_bound_readonly_handler(MockTestBackendHandler::new()).await; let request = make_group_search_request( LdapFilter::Substring("member".to_owned(), LdapSubstringFilter::default()), vec!["cn"], @@ -1627,6 +1627,24 @@ mod tests { ); } + #[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 mut 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();