server: Serialize attribute values when searching

This should fix #763 and allow filtering by custom attribute values.
This commit is contained in:
Valentin Tolmer
2024-01-07 13:04:30 +01:00
committed by nitnelave
parent 337101edea
commit c4be7f5b6f
10 changed files with 216 additions and 118 deletions

View File

@@ -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<_>>>()?,
),
})
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
pub mod deserialize;
pub mod error;
pub mod handler;
pub mod ldap;

View File

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