server: clean up the attributes, relax the substring filter conditions

This consolidates both user and group attributes in their map_{user,group}_attribute as the only point of parsing. It adds support for custom attribute filters for groups, and makes a SubString filter on an unknown attribute resolve to just false.
This commit is contained in:
Valentin Tolmer 2024-01-17 23:29:19 +01:00 committed by nitnelave
parent 4adb636d53
commit bd0a58b476
7 changed files with 283 additions and 163 deletions

View File

@ -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<bool> for GroupRequestFilter {

View File

@ -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<Vec<Vec<u8>>> {
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::<SchemaGroupAttributeExtractor>(&group.attributes, &attr, schema)?
}
_ => {
if !ignored_group_attributes.contains(&attribute) {
match get_custom_attribute::<SchemaGroupAttributeExtractor>(
&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::<SchemaGroupAttributeExtractor>(
&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<GroupRequestFilter> {
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!(

View File

@ -27,78 +27,75 @@ pub fn get_user_attribute(
schema: &PublicSchema,
) -> Option<Vec<Vec<u8>>> {
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::<SchemaUserAttributeExtractor>(
&user.attributes,
&"first_name".into(),
schema,
)?
}
"sn" | "last_name" | "lastname" => get_custom_attribute::<SchemaUserAttributeExtractor>(
&user.attributes,
&"last_name".into(),
schema,
)?,
"jpegphoto" | "avatar" => get_custom_attribute::<SchemaUserAttributeExtractor>(
&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::<SchemaUserAttributeExtractor>(
UserFieldType::PrimaryField(UserColumn::CreationDate) => vec![chrono::Utc
.from_utc_datetime(&user.creation_date)
.to_rfc3339()
.into_bytes()],
UserFieldType::Attribute(attr, _, _) => {
get_custom_attribute::<SchemaUserAttributeExtractor>(&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::<SchemaUserAttributeExtractor>(
&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(),

View File

@ -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()

View File

@ -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;

View File

@ -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(

View File

@ -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();