From a90695a6cedffd33f907d37a21878204b350d3f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:41:32 +0000 Subject: [PATCH] Implement core modifyTimestamp functionality with database migration and backend support Co-authored-by: nitnelave <796633+nitnelave@users.noreply.github.com> --- app/src/infra/attributes.rs | 19 +- crates/domain-model/src/model/groups.rs | 3 + crates/domain-model/src/model/users.rs | 8 + crates/domain/src/types.rs | 6 + crates/graphql-server/src/query.rs | 2 + crates/ldap/src/compare.rs | 2 + crates/ldap/src/core/group.rs | 10 + crates/ldap/src/core/user.rs | 12 + crates/ldap/src/core/utils.rs | 14 +- crates/ldap/src/delete.rs | 2 + crates/ldap/src/handler.rs | 1 + crates/ldap/src/modify.rs | 1 + crates/ldap/src/password.rs | 2 + crates/ldap/src/search.rs | 8 + crates/ldap/src/search.rs.bak | 2132 +++++++++++++++++ .../src/sql_group_backend_handler.rs | 2 + .../sql-backend-handler/src/sql_migrations.rs | 140 ++ .../src/sql_opaque_handler.rs | 2 + crates/sql-backend-handler/src/sql_tables.rs | 2 +- .../src/sql_user_backend_handler.rs | 2 + 20 files changed, 2365 insertions(+), 5 deletions(-) create mode 100644 crates/ldap/src/search.rs.bak diff --git a/app/src/infra/attributes.rs b/app/src/infra/attributes.rs index dbddeee..866fe93 100644 --- a/app/src/infra/attributes.rs +++ b/app/src/infra/attributes.rs @@ -13,7 +13,12 @@ pub mod group { "creation_date" => Some(AttributeDescription { attribute_identifier: name, attribute_name: "creationdate", - aliases: vec![name, "createtimestamp", "modifytimestamp"], + aliases: vec![name, "createtimestamp"], + }), + "modified_date" => Some(AttributeDescription { + attribute_identifier: name, + attribute_name: "modifydate", + aliases: vec![name, "modifytimestamp"], }), "display_name" => Some(AttributeDescription { attribute_identifier: name, @@ -60,7 +65,17 @@ pub mod user { "creation_date" => Some(AttributeDescription { attribute_identifier: name, attribute_name: "creationdate", - aliases: vec![name, "createtimestamp", "modifytimestamp"], + aliases: vec![name, "createtimestamp"], + }), + "modified_date" => Some(AttributeDescription { + attribute_identifier: name, + attribute_name: "modifydate", + aliases: vec![name, "modifytimestamp"], + }), + "password_modified_date" => Some(AttributeDescription { + attribute_identifier: name, + attribute_name: "passwordmodifydate", + aliases: vec![name, "pwdchangedtime"], }), "display_name" => Some(AttributeDescription { attribute_identifier: name, diff --git a/crates/domain-model/src/model/groups.rs b/crates/domain-model/src/model/groups.rs index a44d10e..d3ae888 100644 --- a/crates/domain-model/src/model/groups.rs +++ b/crates/domain-model/src/model/groups.rs @@ -14,6 +14,7 @@ pub struct Model { pub lowercase_display_name: String, pub creation_date: chrono::NaiveDateTime, pub uuid: Uuid, + pub modified_date: chrono::NaiveDateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -39,6 +40,7 @@ impl From for lldap_domain::types::Group { uuid: group.uuid, users: vec![], attributes: Vec::new(), + modified_date: group.modified_date, } } } @@ -51,6 +53,7 @@ impl From for lldap_domain::types::GroupDetails { creation_date: group.creation_date, uuid: group.uuid, attributes: Vec::new(), + modified_date: group.modified_date, } } } diff --git a/crates/domain-model/src/model/users.rs b/crates/domain-model/src/model/users.rs index 50cf377..bf3e78b 100644 --- a/crates/domain-model/src/model/users.rs +++ b/crates/domain-model/src/model/users.rs @@ -21,6 +21,8 @@ pub struct Model { pub totp_secret: Option, pub mfa_type: Option, pub uuid: Uuid, + pub modified_date: chrono::NaiveDateTime, + pub password_modified_date: chrono::NaiveDateTime, } impl EntityName for Entity { @@ -40,6 +42,8 @@ pub enum Column { TotpSecret, MfaType, Uuid, + ModifiedDate, + PasswordModifiedDate, } impl ColumnTrait for Column { @@ -56,6 +60,8 @@ impl ColumnTrait for Column { Column::TotpSecret => ColumnType::String(StringLen::N(64)), Column::MfaType => ColumnType::String(StringLen::N(64)), Column::Uuid => ColumnType::String(StringLen::N(36)), + Column::ModifiedDate => ColumnType::DateTime, + Column::PasswordModifiedDate => ColumnType::DateTime, } .def() } @@ -121,6 +127,8 @@ impl From for lldap_domain::types::User { creation_date: user.creation_date, uuid: user.uuid, attributes: Vec::new(), + modified_date: user.modified_date, + password_modified_date: user.password_modified_date, } } } diff --git a/crates/domain/src/types.rs b/crates/domain/src/types.rs index df02ce2..6bc4718 100644 --- a/crates/domain/src/types.rs +++ b/crates/domain/src/types.rs @@ -546,6 +546,8 @@ pub struct User { pub creation_date: NaiveDateTime, pub uuid: Uuid, pub attributes: Vec, + pub modified_date: NaiveDateTime, + pub password_modified_date: NaiveDateTime, } #[cfg(feature = "test")] @@ -559,6 +561,8 @@ impl Default for User { creation_date: epoch, uuid: Uuid::from_name_and_date("", &epoch), attributes: Vec::new(), + modified_date: epoch, + password_modified_date: epoch, } } } @@ -654,6 +658,7 @@ pub struct Group { pub uuid: Uuid, pub users: Vec, pub attributes: Vec, + pub modified_date: NaiveDateTime, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -663,6 +668,7 @@ pub struct GroupDetails { pub creation_date: NaiveDateTime, pub uuid: Uuid, pub attributes: Vec, + pub modified_date: NaiveDateTime, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/graphql-server/src/query.rs b/crates/graphql-server/src/query.rs index e16c301..5a5eb92 100644 --- a/crates/graphql-server/src/query.rs +++ b/crates/graphql-server/src/query.rs @@ -958,6 +958,7 @@ mod tests { name: "club_name".into(), value: "Gang of Four".to_string().into(), }], + modified_date: chrono::Utc.timestamp_nanos(42).naive_utc(), }); groups.insert(GroupDetails { group_id: GroupId(7), @@ -965,6 +966,7 @@ mod tests { creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(), uuid: lldap_domain::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_nanos(12).naive_utc(), }); mock.expect_get_user_groups() .with(eq(UserId::new("bob"))) diff --git a/crates/ldap/src/compare.rs b/crates/ldap/src/compare.rs index 361d5e7..5f5fc0c 100644 --- a/crates/ldap/src/compare.rs +++ b/crates/ldap/src/compare.rs @@ -124,6 +124,7 @@ mod tests { users: vec![UserId::new("bob")], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; @@ -218,6 +219,7 @@ mod tests { users: vec![UserId::new("bob")], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); let ldap_handler = setup_bound_admin_handler(mock).await; diff --git a/crates/ldap/src/core/group.rs b/crates/ldap/src/core/group.rs index de28376..0bc7a09 100644 --- a/crates/ldap/src/core/group.rs +++ b/crates/ldap/src/core/group.rs @@ -72,6 +72,12 @@ pub fn get_group_attribute( .to_rfc3339() .into_bytes(), ], + GroupFieldType::ModifiedDate => vec![ + chrono::Utc + .from_utc_datetime(&group.modified_date) + .to_rfc3339() + .into_bytes(), + ], GroupFieldType::Member => group .users .iter() @@ -260,6 +266,10 @@ fn convert_group_filter( code: LdapResultCode::UnwillingToPerform, message: "Creation date filter for groups not supported".to_owned(), }), + GroupFieldType::ModifiedDate => Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: "Modified date filter for groups not supported".to_owned(), + }), } } LdapFilter::And(filters) => Ok(GroupRequestFilter::And( diff --git a/crates/ldap/src/core/user.rs b/crates/ldap/src/core/user.rs index b5d000a..4fd664e 100644 --- a/crates/ldap/src/core/user.rs +++ b/crates/ldap/src/core/user.rs @@ -93,6 +93,18 @@ pub fn get_user_attribute( .to_rfc3339() .into_bytes(), ], + UserFieldType::PrimaryField(UserColumn::ModifiedDate) => vec![ + chrono::Utc + .from_utc_datetime(&user.modified_date) + .to_rfc3339() + .into_bytes(), + ], + UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) => vec![ + chrono::Utc + .from_utc_datetime(&user.password_modified_date) + .to_rfc3339() + .into_bytes(), + ], UserFieldType::Attribute(attr, _, _) => get_custom_attribute(&user.attributes, &attr)?, UserFieldType::NoMatch => match attribute.as_str() { "1.1" => return None, diff --git a/crates/ldap/src/core/utils.rs b/crates/ldap/src/core/utils.rs index 8d70c4b..939494f 100644 --- a/crates/ldap/src/core/utils.rs +++ b/crates/ldap/src/core/utils.rs @@ -239,9 +239,15 @@ pub fn map_user_field(field: &AttributeName, schema: &PublicSchema) -> UserField AttributeType::JpegPhoto, false, ), - "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => { + "creationdate" | "createtimestamp" | "creation_date" => { UserFieldType::PrimaryField(UserColumn::CreationDate) } + "modifytimestamp" | "modifydate" | "modified_date" => { + UserFieldType::PrimaryField(UserColumn::ModifiedDate) + } + "pwdchangedtime" | "passwordmodifydate" | "password_modified_date" => { + UserFieldType::PrimaryField(UserColumn::PasswordModifiedDate) + } "entryuuid" | "uuid" => UserFieldType::PrimaryField(UserColumn::Uuid), _ => schema .get_schema() @@ -257,6 +263,7 @@ pub enum GroupFieldType { GroupId, DisplayName, CreationDate, + ModifiedDate, ObjectClass, Dn, // Like Dn, but returned as part of the attributes. @@ -272,9 +279,12 @@ pub fn map_group_field(field: &AttributeName, schema: &PublicSchema) -> GroupFie "entrydn" => GroupFieldType::EntryDn, "objectclass" => GroupFieldType::ObjectClass, "cn" | "displayname" | "uid" | "display_name" | "id" => GroupFieldType::DisplayName, - "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => { + "creationdate" | "createtimestamp" | "creation_date" => { GroupFieldType::CreationDate } + "modifytimestamp" | "modifydate" | "modified_date" => { + GroupFieldType::ModifiedDate + } "member" | "uniquemember" => GroupFieldType::Member, "entryuuid" | "uuid" => GroupFieldType::Uuid, "group_id" | "groupid" => GroupFieldType::GroupId, diff --git a/crates/ldap/src/delete.rs b/crates/ldap/src/delete.rs index 6d1ad31..86432ce 100644 --- a/crates/ldap/src/delete.rs +++ b/crates/ldap/src/delete.rs @@ -154,6 +154,7 @@ mod tests { uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), users: Vec::new(), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); mock.expect_delete_group() @@ -284,6 +285,7 @@ mod tests { uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), users: Vec::new(), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }]) }); mock.expect_delete_group() diff --git a/crates/ldap/src/handler.rs b/crates/ldap/src/handler.rs index c8473db..7197a3d 100644 --- a/crates/ldap/src/handler.rs +++ b/crates/ldap/src/handler.rs @@ -398,6 +398,7 @@ pub mod tests { creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }); Ok(set) }); diff --git a/crates/ldap/src/modify.rs b/crates/ldap/src/modify.rs index 545d1bf..d9d9f7d 100644 --- a/crates/ldap/src/modify.rs +++ b/crates/ldap/src/modify.rs @@ -158,6 +158,7 @@ mod tests { creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }); } Ok(g) diff --git a/crates/ldap/src/password.rs b/crates/ldap/src/password.rs index aa8101b..c1d482c 100644 --- a/crates/ldap/src/password.rs +++ b/crates/ldap/src/password.rs @@ -263,6 +263,7 @@ pub mod tests { creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }); Ok(set) }); @@ -520,6 +521,7 @@ pub mod tests { creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), }); mock.expect_get_user_groups() .with(eq(UserId::new("bob"))) diff --git a/crates/ldap/src/search.rs b/crates/ldap/src/search.rs index 803fe77..2458b0c 100644 --- a/crates/ldap/src/search.rs +++ b/crates/ldap/src/search.rs @@ -840,6 +840,14 @@ mod tests { .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) .unwrap() .naive_utc(), + modified_date: Utc + .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) + .unwrap() + .naive_utc(), + password_modified_date: Utc + .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) + .unwrap() + .naive_utc(), }, groups: None, }, diff --git a/crates/ldap/src/search.rs.bak b/crates/ldap/src/search.rs.bak new file mode 100644 index 0000000..a40e188 --- /dev/null +++ b/crates/ldap/src/search.rs.bak @@ -0,0 +1,2132 @@ +use crate::core::{ + error::{LdapError, LdapResult}, + group::{convert_groups_to_ldap_op, get_groups_list}, + user::{convert_users_to_ldap_op, get_user_list}, + utils::{LdapInfo, LdapSchemaDescription, is_subtree, parse_distinguished_name}, +}; +use chrono::Utc; +use ldap3_proto::{ + LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, LdapSearchScope, + proto::{ + LdapDerefAliases, LdapOp, LdapResult as LdapResultOp, LdapSearchRequest, + OID_PASSWORD_MODIFY, OID_WHOAMI, + }, +}; +use lldap_access_control::UserAndGroupListerBackendHandler; +use lldap_domain::{ + public_schema::PublicSchema, + types::{Group, UserAndGroups}, +}; +use tracing::{debug, instrument, warn}; + +#[derive(Debug)] +enum SearchScope { + Global, + Users, + Groups, + User(LdapFilter), + Group(LdapFilter), + UserOuOnly, + GroupOuOnly, + Unknown, + Invalid, +} + +enum InternalSearchResults { + UsersAndGroups(Vec, Vec), + Raw(Vec), + Empty, +} + +fn get_search_scope( + base_dn: &[(String, String)], + dn_parts: &[(String, String)], + ldap_scope: &LdapSearchScope, +) -> SearchScope { + let base_dn_len = base_dn.len(); + if !is_subtree(dn_parts, base_dn) { + SearchScope::Invalid + } else if dn_parts.len() == base_dn_len { + SearchScope::Global + } else if dn_parts.len() == base_dn_len + 1 + && dn_parts[0] == ("ou".to_string(), "people".to_string()) + { + if matches!(ldap_scope, LdapSearchScope::Base) { + SearchScope::UserOuOnly + } else { + SearchScope::Users + } + } else if dn_parts.len() == base_dn_len + 1 + && dn_parts[0] == ("ou".to_string(), "groups".to_string()) + { + if matches!(ldap_scope, LdapSearchScope::Base) { + SearchScope::GroupOuOnly + } else { + SearchScope::Groups + } + } else if dn_parts.len() == base_dn_len + 2 + && dn_parts[1] == ("ou".to_string(), "people".to_string()) + { + SearchScope::User(LdapFilter::Equality( + dn_parts[0].0.clone(), + dn_parts[0].1.clone(), + )) + } else if dn_parts.len() == base_dn_len + 2 + && dn_parts[1] == ("ou".to_string(), "groups".to_string()) + { + SearchScope::Group(LdapFilter::Equality( + dn_parts[0].0.clone(), + dn_parts[0].1.clone(), + )) + } else { + SearchScope::Unknown + } +} + +pub(crate) fn make_search_request>( + base: &str, + filter: LdapFilter, + attrs: Vec, +) -> LdapSearchRequest { + LdapSearchRequest { + base: base.to_string(), + scope: LdapSearchScope::Subtree, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter, + attrs: attrs.into_iter().map(Into::into).collect(), + } +} + +pub(crate) fn make_search_success() -> LdapOp { + make_search_error(LdapResultCode::Success, "".to_string()) +} + +pub(crate) fn make_search_error(code: LdapResultCode, message: String) -> LdapOp { + LdapOp::SearchResultDone(LdapResultOp { + code, + matcheddn: "".to_string(), + message, + referral: vec![], + }) +} + +pub(crate) fn root_dse_response(base_dn: &str) -> LdapOp { + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![b"top".to_vec()], + }, + LdapPartialAttribute { + atype: "vendorName".to_string(), + vals: vec![b"LLDAP".to_vec()], + }, + LdapPartialAttribute { + atype: "vendorVersion".to_string(), + vals: vec![ + concat!("lldap_", env!("CARGO_PKG_VERSION")) + .to_string() + .into_bytes(), + ], + }, + LdapPartialAttribute { + atype: "supportedLDAPVersion".to_string(), + vals: vec![b"3".to_vec()], + }, + LdapPartialAttribute { + atype: "supportedExtension".to_string(), + vals: vec![ + OID_PASSWORD_MODIFY.as_bytes().to_vec(), + OID_WHOAMI.as_bytes().to_vec(), + ], + }, + LdapPartialAttribute { + atype: "supportedControl".to_string(), + vals: vec![], + }, + LdapPartialAttribute { + atype: "supportedFeatures".to_string(), + // Attribute "+" + vals: vec![b"1.3.6.1.4.1.4203.1.5.1".to_vec()], + }, + LdapPartialAttribute { + atype: "defaultNamingContext".to_string(), + vals: vec![base_dn.to_string().into_bytes()], + }, + LdapPartialAttribute { + atype: "namingContexts".to_string(), + vals: vec![base_dn.to_string().into_bytes()], + }, + LdapPartialAttribute { + atype: "isGlobalCatalogReady".to_string(), + vals: vec![b"false".to_vec()], + }, + LdapPartialAttribute { + atype: "subschemaSubentry".to_string(), + vals: vec![b"cn=Subschema".to_vec()], + }, + ], + }) +} + +pub fn make_ldap_subschema_entry(schema: PublicSchema) -> LdapOp { + let ldap_schema_description: LdapSchemaDescription = LdapSchemaDescription::from(schema); + let current_time_utc = Utc::now().format("%Y%m%d%H%M%SZ").to_string().into_bytes(); + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=Subschema".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "structuralObjectClass".to_string(), + vals: vec![b"subentry".to_vec()], + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![b"top".to_vec(), b"subentry".to_vec(), b"subschema".to_vec(), b"extensibleObject".to_vec()], + }, + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"Subschema".to_vec()], + }, + LdapPartialAttribute { + atype: "createTimestamp".to_string(), + vals: vec![current_time_utc.to_vec()], + }, + LdapPartialAttribute { + atype: "modifyTimestamp".to_string(), + vals: vec![current_time_utc.to_vec()], + }, + LdapPartialAttribute { + atype: "ldapSyntaxes".to_string(), + vals: vec![ + b"( 1.3.6.1.1.16.1 DESC 'UUID' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.3 DESC 'Attribute Type Description' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.12 DESC 'Distinguished Name' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.34 DESC 'Name And Optional UID' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.37 DESC 'Object Class Description' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.38 DESC 'OID' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.54 DESC 'LDAP Syntax Description' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.58 DESC 'Substring Assertion' )".to_vec(), + ], + }, + LdapPartialAttribute { + atype: "matchingRules".to_string(), + vals: vec![ + b"( 1.3.6.1.1.16.2 NAME 'UUIDMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), + b"( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), + b"( 2.5.13.0 NAME 'objectIdentifierMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), + b"( 2.5.13.1 NAME 'distinguishedNameMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), + b"( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), + b"( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )".to_vec(), + b"( 2.5.13.23 NAME 'uniqueMemberMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), + b"( 2.5.13.27 NAME 'generalizedTimeMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + b"( 2.5.13.28 NAME 'generalizedTimeOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + b"( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), + ], + }, + LdapPartialAttribute { + atype: "attributeTypes".to_string(), + vals: { + let hardcoded_attributes = [ + b"( 0.9.2342.19200300.100.1.1 NAME ( 'uid' 'userid' 'user_id' ) DESC 'RFC4519: user identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE NO-USER-MODIFICATION )".to_vec(), + b"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 NO-USER-MODIFICATION USAGE dSAOperation X-ORIGIN 'iPlanet Delegated Administrator' )".to_vec(), + b"( 1.3.6.1.1.16.4 NAME ( 'entryUUID' 'uuid' ) DESC 'UUID of the entry' EQUALITY UUIDMatch ORDERING UUIDOrderingMatch SYNTAX 1.3.6.1.1.16.1 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC4512: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )".to_vec(), + b"( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), + b"( 2.5.4.3 NAME ( 'cn' 'commonName' 'display_name' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), + b"( 2.5.4.4 NAME ( 'sn' 'surname' 'last_name' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), + b"( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name )".to_vec(), + b"( 2.5.4.41 NAME 'name' DESC 'RFC4519: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )".to_vec(), + b"( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC4519: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), + b"( 2.5.4.50 NAME ( 'uniqueMember' 'member' ) DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), + b"( 2.5.18.1 NAME ( 'createTimestamp' 'creation_date' ) DESC 'RFC4512: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC4512: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC4512: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )".to_vec(), + b"( 2.5.21.6 NAME 'objectClasses' DESC 'RFC4512: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )".to_vec(), + b"( 2.5.21.9 NAME 'structuralObjectClass' DESC 'RFC4512: structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 10.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), + b"( 10.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), + b"( 10.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), + b"( 10.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + ]; + hardcoded_attributes.into_iter().chain( + ldap_schema_description + .formatted_attribute_list( + 4, // The number of hardcoded attributes starting with "10." (LLDAP custom range) + vec!["creation_date", "display_name", "last_name", "user_id", "uuid"] + ) + ).collect() + } + }, + LdapPartialAttribute { + atype: "objectClasses".to_string(), + vals: vec![ + format!( + "( 3.0 NAME ( {} ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( {} ) MAY ( {} ) )", + ldap_schema_description.user_object_classes().format_for_ldap_schema_description(), + ldap_schema_description.required_user_attributes().format_for_ldap_schema_description(), + ldap_schema_description.optional_user_attributes().format_for_ldap_schema_description(), + ).into_bytes(), + format!( + "( 3.1 NAME ( {} ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( {} ) MAY ( {} ) )", + ldap_schema_description.group_object_classes().format_for_ldap_schema_description(), + ldap_schema_description.required_group_attributes().format_for_ldap_schema_description(), + ldap_schema_description.optional_group_attributes().format_for_ldap_schema_description(), + ).into_bytes(), + ], + }, + LdapPartialAttribute { + atype: "subschemaSubentry".to_string(), + vals: vec![b"cn=Subschema".to_vec()], + }, + ], + }) +} + +pub(crate) fn is_root_dse_request(request: &LdapSearchRequest) -> bool { + if request.base.is_empty() && request.scope == LdapSearchScope::Base { + if let LdapFilter::Present(attribute) = &request.filter { + if attribute.eq_ignore_ascii_case("objectclass") { + return true; + } + } + } + false +} + +pub(crate) fn is_subschema_entry_request(request: &LdapSearchRequest) -> bool { + request.base == "cn=Subschema" && request.scope == LdapSearchScope::Base +} + +async fn do_search_internal( + ldap_info: &LdapInfo, + backend_handler: &impl UserAndGroupListerBackendHandler, + request: &LdapSearchRequest, + schema: &PublicSchema, +) -> LdapResult { + let dn_parts = parse_distinguished_name(&request.base.to_ascii_lowercase())?; + let scope = get_search_scope(&ldap_info.base_dn, &dn_parts, &request.scope); + debug!(?request.base, ?scope); + // Disambiguate the lifetimes. + fn cast<'a, T, R>(x: T) -> T + where + T: Fn(&'a LdapFilter) -> R + 'a, + { + x + } + + let get_user_list = cast(async |filter: &LdapFilter| { + let need_groups = request + .attrs + .iter() + .any(|s| s.eq_ignore_ascii_case("memberof")); + get_user_list( + ldap_info, + filter, + need_groups, + &request.base, + backend_handler, + schema, + ) + .await + }); + let get_group_list = cast(|filter: &LdapFilter| async { + get_groups_list(ldap_info, filter, &request.base, backend_handler, schema).await + }); + Ok(match scope { + SearchScope::Global => { + let users = get_user_list(&request.filter).await; + let groups = get_group_list(&request.filter).await; + match (users, groups) { + (Ok(users), Err(e)) => { + warn!("Error while getting groups: {:#}", e); + InternalSearchResults::UsersAndGroups(users, Vec::new()) + } + (Err(e), Ok(groups)) => { + warn!("Error while getting users: {:#}", e); + InternalSearchResults::UsersAndGroups(Vec::new(), groups) + } + (Err(user_error), Err(_)) => InternalSearchResults::Raw(vec![make_search_error( + user_error.code, + user_error.message, + )]), + (Ok(users), Ok(groups)) => InternalSearchResults::UsersAndGroups(users, groups), + } + } + SearchScope::Users => { + InternalSearchResults::UsersAndGroups(get_user_list(&request.filter).await?, Vec::new()) + } + SearchScope::Groups => InternalSearchResults::UsersAndGroups( + Vec::new(), + get_group_list(&request.filter).await?, + ), + SearchScope::User(filter) => { + let filter = LdapFilter::And(vec![request.filter.clone(), filter]); + InternalSearchResults::UsersAndGroups(get_user_list(&filter).await?, Vec::new()) + } + SearchScope::Group(filter) => { + let filter = LdapFilter::And(vec![request.filter.clone(), filter]); + InternalSearchResults::UsersAndGroups(Vec::new(), get_group_list(&filter).await?) + } + SearchScope::UserOuOnly | SearchScope::GroupOuOnly => { + InternalSearchResults::Raw(vec![LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: request.base.clone(), + attributes: vec![LdapPartialAttribute { + atype: "objectClass".to_owned(), + vals: vec![b"top".to_vec(), b"organizationalUnit".to_vec()], + }], + })]) + } + SearchScope::Unknown => { + warn!( + r#"The requested search tree "{}" matches neither the user subtree "ou=people,{}" nor the group subtree "ou=groups,{}""#, + &request.base, &ldap_info.base_dn_str, &ldap_info.base_dn_str + ); + InternalSearchResults::Empty + } + SearchScope::Invalid => { + // Search path is not in our tree, just return an empty success. + warn!( + "The specified search tree {:?} is not under the common subtree {:?}", + &dn_parts, &ldap_info.base_dn + ); + InternalSearchResults::Empty + } + }) +} + +#[instrument(skip_all, level = "debug")] +pub async fn do_search( + backend_handler: &impl UserAndGroupListerBackendHandler, + ldap_info: &LdapInfo, + request: &LdapSearchRequest, +) -> LdapResult> { + 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 = do_search_internal(ldap_info, backend_handler, request, &schema).await?; + let mut results = match search_results { + InternalSearchResults::UsersAndGroups(users, groups) => { + convert_users_to_ldap_op(users, &request.attrs, ldap_info, &schema) + .chain(convert_groups_to_ldap_op( + groups, + &request.attrs, + ldap_info, + backend_handler.user_filter(), + &schema, + )) + .collect() + } + InternalSearchResults::Raw(raw_results) => raw_results, + InternalSearchResults::Empty => Vec::new(), + }; + if !matches!(results.last(), Some(LdapOp::SearchResultDone(_))) { + results.push(make_search_success()); + } + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + core::error::LdapError, + handler::tests::{ + make_group_search_request, make_user_search_request, setup_bound_admin_handler, + setup_bound_handler_with_group, setup_bound_readonly_handler, + }, + }; + use chrono::{DateTime, Duration, NaiveDateTime, TimeZone}; + use ldap3_proto::proto::{LdapDerefAliases, LdapSearchScope, LdapSubstringFilter}; + use lldap_domain::{ + schema::{AttributeList, AttributeSchema, Schema}, + types::{ + Attribute, AttributeName, AttributeType, GroupDetails, GroupId, JpegPhoto, + LdapObjectClass, User, UserId, + }, + uuid, + }; + use lldap_domain_handlers::handler::*; + use lldap_domain_model::model::UserColumn; + use lldap_test_utils::MockTestBackendHandler; + use mockall::predicate::eq; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn test_search_root_dse() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let request = LdapSearchRequest { + base: "".to_string(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter: LdapFilter::Present("objectClass".to_string()), + attrs: vec!["supportedExtension".to_string()], + }; + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + root_dse_response("dc=example,dc=com"), + make_search_success() + ]) + ); + } + + fn assert_timestamp_within_margin( + timestamp_bytes: &[u8], + base_timestamp_dt: DateTime, + time_margin: Duration, + ) { + let timestamp_str = + std::str::from_utf8(timestamp_bytes).expect("Invalid conversion from UTF-8 to string"); + let timestamp_naive = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%SZ") + .expect("Invalid timestamp format"); + let timestamp_dt: DateTime = Utc.from_utc_datetime(×tamp_naive); + + let within_range = (base_timestamp_dt - timestamp_dt).abs() <= time_margin; + + assert!( + within_range, + "Timestamp not within range: expected within [{} - {}], got [{}]", + base_timestamp_dt - time_margin, + base_timestamp_dt + time_margin, + timestamp_dt + ); + } + + #[tokio::test] + async fn test_subschema_response() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + + let request = LdapSearchRequest { + base: "cn=Subschema".to_string(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter: LdapFilter::Present("objectClass".to_string()), + attrs: vec!["supportedExtension".to_string()], + }; + + let actual_reponse: Vec = ldap_handler.do_search_or_dse(&request).await.unwrap(); + + let LdapOp::SearchResultEntry(search_result_entry) = &actual_reponse[0] else { + panic!("Expected SearchResultEntry"); + }; + + let attrs = &search_result_entry.attributes; + assert_eq!(attrs.len(), 10); + assert_eq!(search_result_entry.dn, "cn=Subschema".to_owned()); + + assert_eq!( + attrs[0], + LdapPartialAttribute { + atype: "structuralObjectClass".to_owned(), + vals: vec![b"subentry".to_vec()] + } + ); + + assert_eq!( + attrs[1], + LdapPartialAttribute { + atype: "objectClass".to_owned(), + vals: vec![ + b"top".to_vec(), + b"subentry".to_vec(), + b"subschema".to_vec(), + b"extensibleObject".to_vec() + ] + } + ); + + assert_eq!( + attrs[2], + LdapPartialAttribute { + atype: "cn".to_owned(), + vals: vec![b"Subschema".to_vec()] + } + ); + + let check_timestamp_attribute = |attr: &LdapPartialAttribute, expected_type: &str| { + assert_eq!(attr.atype, expected_type); + assert_eq!(attr.vals.len(), 1); + assert_timestamp_within_margin(&attr.vals[0], Utc::now(), Duration::seconds(300)); + }; + check_timestamp_attribute(&attrs[3], "createTimestamp"); + check_timestamp_attribute(&attrs[4], "modifyTimestamp"); + + assert_eq!( + attrs[5], + LdapPartialAttribute { + atype: "ldapSyntaxes".to_owned(), + vals: vec![ + b"( 1.3.6.1.1.16.1 DESC 'UUID' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.3 DESC 'Attribute Type Description' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.12 DESC 'Distinguished Name' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.24 DESC 'Generalized Time' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.28 DESC 'JPEG' X-NOT-HUMAN-READABLE 'TRUE' )" + .to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.34 DESC 'Name And Optional UID' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.37 DESC 'Object Class Description' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.38 DESC 'OID' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.54 DESC 'LDAP Syntax Description' )".to_vec(), + b"( 1.3.6.1.4.1.1466.115.121.1.58 DESC 'Substring Assertion' )".to_vec(), + ] + } + ); + + assert_eq!( + attrs[6], + LdapPartialAttribute { + atype: "matchingRules".to_string(), + vals: vec![ + b"( 1.3.6.1.1.16.2 NAME 'UUIDMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), + b"( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )".to_vec(), + b"( 2.5.13.0 NAME 'objectIdentifierMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), + b"( 2.5.13.1 NAME 'distinguishedNameMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), + b"( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), + b"( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )".to_vec(), + b"( 2.5.13.23 NAME 'uniqueMemberMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), + b"( 2.5.13.27 NAME 'generalizedTimeMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + b"( 2.5.13.28 NAME 'generalizedTimeOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + b"( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), + ] + } + ); + + assert_eq!( + attrs[7], + LdapPartialAttribute { + atype: "attributeTypes".to_owned(), + vals: vec![ + b"( 0.9.2342.19200300.100.1.1 NAME ( 'uid' 'userid' 'user_id' ) DESC 'RFC4519: user identifier' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} SINGLE-VALUE NO-USER-MODIFICATION )".to_vec(), + b"( 1.2.840.113556.1.2.102 NAME 'memberOf' DESC 'Group that the entry belongs to' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 NO-USER-MODIFICATION USAGE dSAOperation X-ORIGIN 'iPlanet Delegated Administrator' )".to_vec(), + b"( 1.3.6.1.1.16.4 NAME ( 'entryUUID' 'uuid' ) DESC 'UUID of the entry' EQUALITY UUIDMatch ORDERING UUIDOrderingMatch SYNTAX 1.3.6.1.1.16.1 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC4512: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )".to_vec(), + b"( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )".to_vec(), + b"( 2.5.4.3 NAME ( 'cn' 'commonName' 'display_name' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), + b"( 2.5.4.4 NAME ( 'sn' 'surname' 'last_name' ) DESC 'RFC2256: last (family) name(s) for which the entity is known by' SUP name SINGLE-VALUE )".to_vec(), + b"( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' ) DESC 'RFC2256: organizational unit this object belongs to' SUP name )".to_vec(), + b"( 2.5.4.41 NAME 'name' DESC 'RFC4519: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )".to_vec(), + b"( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC4519: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )".to_vec(), + b"( 2.5.4.50 NAME ( 'uniqueMember' 'member' ) DESC 'RFC2256: unique member of a group' EQUALITY uniqueMemberMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )".to_vec(), + b"( 2.5.18.1 NAME ( 'createTimestamp' 'creation_date' ) DESC 'RFC4512: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC4512: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC4512: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )".to_vec(), + b"( 2.5.21.6 NAME 'objectClasses' DESC 'RFC4512: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )".to_vec(), + b"( 2.5.21.9 NAME 'structuralObjectClass' DESC 'RFC4512: structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )".to_vec(), + b"( 10.0 NAME 'String' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )".to_vec(), + b"( 10.1 NAME 'Integer' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )".to_vec(), + b"( 10.2 NAME 'JpegPhoto' SYNTAX 1.3.6.1.4.1.1466.115.121.1.28 )".to_vec(), + b"( 10.3 NAME 'DateTime' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )".to_vec(), + b"( 10.4 NAME 'avatar' DESC 'LLDAP: builtin attribute' SUP JpegPhoto )".to_vec(), + b"( 10.5 NAME 'first_name' DESC 'LLDAP: builtin attribute' SUP String )" + .to_vec(), + b"( 10.6 NAME 'mail' DESC 'LLDAP: builtin attribute' SUP String )".to_vec(), + b"( 10.7 NAME 'group_id' DESC 'LLDAP: builtin attribute' SUP Integer )" + .to_vec(), + ] + } + ); + + assert_eq!(attrs[8], + LdapPartialAttribute { + atype: "objectClasses".to_owned(), + vals: vec![ + b"( 3.0 NAME ( 'inetOrgPerson' 'posixAccount' 'mailAccount' 'person' 'customUserClass' ) DESC 'LLDAP builtin: a person' STRUCTURAL MUST ( mail $ user_id ) MAY ( avatar $ creation_date $ display_name $ first_name $ last_name $ uuid ) )".to_vec(), + b"( 3.1 NAME ( 'groupOfUniqueNames' 'groupOfNames' ) DESC 'LLDAP builtin: a group' STRUCTURAL MUST ( display_name ) MAY ( creation_date $ group_id $ uuid ) )".to_vec(), + ] + } + ); + + assert_eq!( + attrs[9], + LdapPartialAttribute { + atype: "subschemaSubentry".to_owned(), + vals: vec![b"cn=Subschema".to_vec()] + } + ); + + assert_eq!(actual_reponse[1], make_search_success()); + } + + #[tokio::test] + async fn test_search_regular_user() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with( + eq(Some(UserRequestFilter::And(vec![ + UserRequestFilter::And(Vec::new()), + UserRequestFilter::UserId(UserId::new("test")), + ]))), + eq(false), + ) + .times(1) + .return_once(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("test"), + ..Default::default() + }, + groups: None, + }]) + }); + let ldap_handler = setup_bound_handler_with_group(mock, "regular").await; + + let request = + make_user_search_request::(LdapFilter::And(vec![]), vec!["1.1".to_string()]); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=test,ou=people,dc=example,dc=com".to_string(), + attributes: vec![], + }), + make_search_success() + ]), + ); + } + + #[tokio::test] + async fn test_search_readonly_user() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with(eq(Some(UserRequestFilter::And(Vec::new()))), eq(false)) + .times(1) + .return_once(|_, _| Ok(vec![])); + let ldap_handler = setup_bound_readonly_handler(mock).await; + + let request = + make_user_search_request::(LdapFilter::And(vec![]), vec!["1.1".to_string()]); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]), + ); + } + + #[tokio::test] + async fn test_search_member_of() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with(eq(Some(UserRequestFilter::And(Vec::new()))), eq(true)) + .times(1) + .return_once(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob"), + ..Default::default() + }, + groups: Some(vec![GroupDetails { + group_id: GroupId(42), + display_name: "rockstars".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: Vec::new(), + }]), + }]) + }); + let ldap_handler = setup_bound_readonly_handler(mock).await; + + let request = make_user_search_request::( + LdapFilter::And(vec![]), + vec!["memberOf".to_string()], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=bob,ou=people,dc=example,dc=com".to_string(), + attributes: vec![LdapPartialAttribute { + atype: "memberOf".to_string(), + vals: vec![b"cn=rockstars,ou=groups,dc=example,dc=com".to_vec()] + }], + }), + make_search_success(), + ]), + ); + } + + #[tokio::test] + async fn test_search_user_as_scope() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with( + eq(Some(UserRequestFilter::And(vec![ + UserRequestFilter::And(Vec::new()), + UserRequestFilter::UserId(UserId::new("bob")), + ]))), + eq(false), + ) + .times(1) + .return_once(|_, _| Ok(vec![])); + let ldap_handler = setup_bound_readonly_handler(mock).await; + + let request = LdapSearchRequest { + base: "uid=bob,ou=people,Dc=example,dc=com".to_string(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter: LdapFilter::And(vec![]), + attrs: vec!["1.1".to_string()], + }; + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]), + ); + } + + #[tokio::test] + async fn test_search_users() { + use chrono::prelude::*; + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().times(1).return_once(|_, _| { + Ok(vec![ + UserAndGroups { + user: User { + user_id: UserId::new("bob_1"), + email: "bob@bobmail.bob".into(), + display_name: Some("Bôb Böbberson".to_string()), + uuid: uuid!("698e1d5f-7a40-3151-8745-b9b8a37839da"), + attributes: vec![ + Attribute { + name: "first_name".into(), + value: "Bôb".to_string().into(), + }, + Attribute { + name: "last_name".into(), + value: "Böbberson".to_string().into(), + }, + ], + ..Default::default() + }, + groups: None, + }, + UserAndGroups { + user: User { + user_id: UserId::new("jim"), + email: "jim@cricket.jim".into(), + display_name: Some("Jimminy Cricket".to_string()), + attributes: vec![ + Attribute { + name: "avatar".into(), + value: JpegPhoto::for_tests().into(), + }, + Attribute { + name: "first_name".into(), + value: "Jim".to_string().into(), + }, + Attribute { + name: "last_name".into(), + value: "Cricket".to_string().into(), + }, + ], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + creation_date: Utc + .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) + .unwrap() + .naive_utc(), + modified_date: Utc + .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) + .unwrap() + .naive_utc(), + password_modified_date: Utc + .with_ymd_and_hms(2014, 7, 8, 9, 10, 11) + .unwrap() + .naive_utc(), + }, + groups: None, + }, + ]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::And(vec![]), + vec![ + "objectClass", + "dn", + "uid", + "mail", + "givenName", + "sn", + "cn", + "createTimestamp", + "entryUuid", + "jpegPhoto", + ], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec!["Bôb Böbberson".to_string().into_bytes()] + }, + LdapPartialAttribute { + atype: "createTimestamp".to_string(), + vals: vec![b"1970-01-01T00:00:00+00:00".to_vec()] + }, + LdapPartialAttribute { + atype: "entryUuid".to_string(), + vals: vec![b"698e1d5f-7a40-3151-8745-b9b8a37839da".to_vec()] + }, + LdapPartialAttribute { + atype: "givenName".to_string(), + vals: vec!["Bôb".to_string().into_bytes()] + }, + LdapPartialAttribute { + atype: "mail".to_string(), + vals: vec![b"bob@bobmail.bob".to_vec()] + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + b"customUserClass".to_vec(), + ] + }, + LdapPartialAttribute { + atype: "sn".to_string(), + vals: vec!["Böbberson".to_string().into_bytes()] + }, + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec![b"bob_1".to_vec()] + }, + ], + }), + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=jim,ou=people,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"Jimminy Cricket".to_vec()] + }, + LdapPartialAttribute { + atype: "createTimestamp".to_string(), + vals: vec![b"2014-07-08T09:10:11+00:00".to_vec()] + }, + LdapPartialAttribute { + atype: "entryUuid".to_string(), + vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()] + }, + LdapPartialAttribute { + atype: "givenName".to_string(), + vals: vec![b"Jim".to_vec()] + }, + LdapPartialAttribute { + atype: "jpegPhoto".to_string(), + vals: vec![JpegPhoto::for_tests().into_bytes()] + }, + LdapPartialAttribute { + atype: "mail".to_string(), + vals: vec![b"jim@cricket.jim".to_vec()] + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + b"customUserClass".to_vec(), + ] + }, + LdapPartialAttribute { + atype: "sn".to_string(), + vals: vec![b"Cricket".to_vec()] + }, + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec![b"jim".to_vec()] + }, + ], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_groups() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::And(Vec::new())))) + .times(1) + .return_once(|_| { + Ok(vec![ + Group { + id: GroupId(1), + display_name: "group_1".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob"), UserId::new("john")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + }, + Group { + id: GroupId(3), + display_name: "BestGroup".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("john")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + }, + ]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_group_search_request( + LdapFilter::And(vec![]), + vec![ + "objectClass", + "dn", + "cn", + "uniqueMember", + "entryUuid", + "entryDN", + ], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"group_1".to_vec()] + }, + LdapPartialAttribute { + atype: "entryDN".to_string(), + vals: vec![b"uid=group_1,ou=groups,dc=example,dc=com".to_vec()], + }, + LdapPartialAttribute { + atype: "entryUuid".to_string(), + vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()], + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], + }, + LdapPartialAttribute { + atype: "uniqueMember".to_string(), + vals: vec![ + b"uid=bob,ou=people,dc=example,dc=com".to_vec(), + b"uid=john,ou=people,dc=example,dc=com".to_vec(), + ] + }, + ], + }), + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=BestGroup,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"BestGroup".to_vec()] + }, + LdapPartialAttribute { + atype: "entryDN".to_string(), + vals: vec![b"uid=BestGroup,ou=groups,dc=example,dc=com".to_vec()], + }, + LdapPartialAttribute { + atype: "entryUuid".to_string(), + vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()], + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], + }, + LdapPartialAttribute { + atype: "uniqueMember".to_string(), + vals: vec![b"uid=john,ou=people,dc=example,dc=com".to_vec()] + }, + ], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_groups_by_groupid() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::GroupId(GroupId(1))))) + .times(1) + .return_once(|_| { + Ok(vec![Group { + id: GroupId(1), + display_name: "group_1".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob"), UserId::new("john")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_group_search_request( + LdapFilter::Equality("groupid".to_string(), "1".to_string()), + vec!["dn"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_groups_filter() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::And(vec![ + GroupRequestFilter::DisplayName("group_1".into()), + GroupRequestFilter::Member(UserId::new("bob")), + GroupRequestFilter::DisplayName("rockstars".into()), + false.into(), + GroupRequestFilter::Uuid(uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc")), + true.into(), + true.into(), + true.into(), + 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(|_| { + Ok(vec![Group { + display_name: "group_1".into(), + id: GroupId(1), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_group_search_request( + LdapFilter::And(vec![ + LdapFilter::Equality("cN".to_string(), "Group_1".to_string()), + LdapFilter::Equality( + "uniqueMember".to_string(), + "uid=bob,ou=peopLe,Dc=eXample,dc=com".to_string(), + ), + LdapFilter::Equality( + "dn".to_string(), + "uid=rockstars,ou=groups,dc=example,dc=com".to_string(), + ), + LdapFilter::Equality( + "dn".to_string(), + "uid=rockstars,ou=people,dc=example,dc=com".to_string(), + ), + LdapFilter::Equality( + "uuid".to_string(), + "04ac75e0-2900-3e21-926c-2f732c26b3fc".to_string(), + ), + LdapFilter::Equality("obJEctclass".to_string(), "groupofUniqueNames".to_string()), + LdapFilter::Equality("objectclass".to_string(), "groupOfNames".to_string()), + LdapFilter::Present("objectclass".to_string()), + LdapFilter::Present("dn".to_string()), + LdapFilter::Not(Box::new(LdapFilter::Present( + "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"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_groups_filter_2() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::Or(vec![ + GroupRequestFilter::Not(Box::new(GroupRequestFilter::DisplayName( + "group_2".into(), + ))), + ])))) + .times(1) + .return_once(|_| { + Ok(vec![Group { + display_name: "group_1".into(), + id: GroupId(1), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_group_search_request( + LdapFilter::Or(vec![LdapFilter::Not(Box::new(LdapFilter::Equality( + "displayname".to_string(), + "group_2".to_string(), + )))]), + vec!["cn"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"group_1".to_vec()] + },], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_groups_filter_3() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::Or(vec![ + GroupRequestFilter::AttributeEquality( + AttributeName::from("attr"), + "TEST".to_string().into(), + ), + GroupRequestFilter::AttributeEquality( + AttributeName::from("attr"), + "test".to_string().into(), + ), + ])))) + .times(1) + .return_once(|_| { + Ok(vec![Group { + display_name: "group_1".into(), + id: GroupId(1), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: vec![Attribute { + name: "Attr".into(), + value: "TEST".to_string().into(), + }], + }]) + }); + mock.expect_get_schema().returning(|| { + Ok(Schema { + user_attributes: AttributeList { + attributes: Vec::new(), + }, + group_attributes: AttributeList { + attributes: vec![AttributeSchema { + name: "Attr".into(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: false, + is_readonly: false, + }], + }, + extra_user_object_classes: Vec::new(), + extra_group_object_classes: Vec::new(), + }) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_group_search_request( + LdapFilter::Equality("Attr".to_string(), "TEST".to_string()), + vec!["cn"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"group_1".to_vec()] + },], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_group_as_scope() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::And(vec![ + GroupRequestFilter::And(Vec::new()), + GroupRequestFilter::DisplayName("rockstars".into()), + ])))) + .times(1) + .return_once(|_| Ok(vec![])); + let ldap_handler = setup_bound_readonly_handler(mock).await; + + let request = LdapSearchRequest { + base: "uid=rockstars,ou=groups,Dc=example,dc=com".to_string(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter: LdapFilter::And(vec![]), + attrs: vec!["1.1".to_string()], + }; + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]), + ); + } + + #[tokio::test] + async fn test_search_groups_unsupported_substring() { + let ldap_handler = setup_bound_readonly_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_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 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(); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::Or(vec![ + GroupRequestFilter::Not(Box::new(GroupRequestFilter::DisplayName( + "group_2".into(), + ))), + ])))) + .times(1) + .return_once(|_| { + Err(lldap_domain_model::error::DomainError::InternalError( + "Error getting groups".to_string(), + )) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_group_search_request( + LdapFilter::Or(vec![LdapFilter::Not(Box::new(LdapFilter::Equality( + "displayname".to_string(), + "group_2".to_string(), + )))]), + vec!["cn"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Err(LdapError{ + code: LdapResultCode::Other, + message: r#"Error while listing groups "ou=groups,dc=example,dc=com": Internal error: `Error getting groups`"#.to_string() + }) + ); + } + + #[tokio::test] + async fn test_search_groups_filter_error() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let request = make_group_search_request( + 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 { + code: LdapResultCode::UnwillingToPerform, + message: r#"Unsupported group filter: Approx("whatever", "value")"#.to_string() + }) + ); + } + + #[tokio::test] + async fn test_search_filters() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with( + eq(Some(UserRequestFilter::And(vec![UserRequestFilter::Or( + vec![ + UserRequestFilter::Not(Box::new(UserRequestFilter::UserId(UserId::new( + "bob", + )))), + UserRequestFilter::UserId("bob_1".to_string().into()), + false.into(), + true.into(), + false.into(), + true.into(), + true.into(), + false.into(), + UserRequestFilter::Or(vec![ + UserRequestFilter::AttributeEquality( + AttributeName::from("first_name"), + "FirstName".to_string().into(), + ), + UserRequestFilter::AttributeEquality( + AttributeName::from("first_name"), + "firstname".to_string().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::DisplayName, + SubStringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), + ], + )]))), + eq(false), + ) + .times(1) + .return_once(|_, _| Ok(vec![])); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::And(vec![LdapFilter::Or(vec![ + LdapFilter::Not(Box::new(LdapFilter::Equality( + "uid".to_string(), + "bob".to_string(), + ))), + LdapFilter::Equality( + "dn".to_string(), + "uid=bob_1,ou=people,dc=example,dc=com".to_string(), + ), + LdapFilter::Equality( + "dn".to_string(), + "uid=bob_1,ou=groups,dc=example,dc=com".to_string(), + ), + LdapFilter::Equality("objectclass".to_string(), "persOn".to_string()), + LdapFilter::Equality("objectclass".to_string(), "other".to_string()), + LdapFilter::Present("objectClass".to_string()), + LdapFilter::Present("uid".to_string()), + LdapFilter::Present("unknown".to_string()), + LdapFilter::Equality("givenname".to_string(), "FirstName".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( + "displayName".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), + ])]), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]) + ); + } + + #[tokio::test] + async fn test_search_unsupported_substring_filter() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let request = make_user_search_request( + LdapFilter::Substring( + "uuid".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), + vec!["objectClass"], + ); + ldap_handler.do_search_or_dse(&request).await.unwrap_err(); + let request = make_user_search_request( + LdapFilter::Substring( + "givenname".to_owned(), + LdapSubstringFilter { + initial: Some("iNIt".to_owned()), + any: vec!["1".to_owned(), "2aA".to_owned()], + final_: Some("finAl".to_owned()), + }, + ), + vec!["objectClass"], + ); + ldap_handler.do_search_or_dse(&request).await.unwrap_err(); + } + + #[tokio::test] + async fn test_search_member_of_filter() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with( + eq(Some(UserRequestFilter::MemberOf("group_1".into()))), + eq(false), + ) + .times(2) + .returning(|_, _| Ok(vec![])); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::Equality( + "memberOf".to_string(), + "cn=group_1, ou=groups, dc=example,dc=com".to_string(), + ), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]) + ); + let request = make_user_search_request( + LdapFilter::Equality("memberOf".to_string(), "group_1".to_string()), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]) + ); + } + #[tokio::test] + async fn test_search_member_of_filter_error() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with(eq(Some(UserRequestFilter::from(false))), eq(false)) + .times(1) + .returning(|_, _| Ok(vec![])); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::Equality( + "memberOf".to_string(), + "cn=mygroup,dc=example,dc=com".to_string(), + ), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + // The error is ignored, a warning is printed. + Ok(vec![make_search_success()]) + ); + } + + #[tokio::test] + async fn test_search_filters_lowercase() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with( + eq(Some(UserRequestFilter::And(vec![UserRequestFilter::Or( + vec![UserRequestFilter::Not(Box::new( + UserRequestFilter::Equality(UserColumn::DisplayName, "bob".to_string()), + ))], + )]))), + eq(false), + ) + .times(1) + .return_once(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob_1"), + ..Default::default() + }, + groups: None, + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::And(vec![LdapFilter::Or(vec![LdapFilter::Not(Box::new( + LdapFilter::Equality("displayname".to_string(), "bob".to_string()), + ))])]), + vec!["objectclass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), + attributes: vec![LdapPartialAttribute { + atype: "objectclass".to_string(), + vals: vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + b"customUserClass".to_vec(), + ] + },] + }), + make_search_success() + ]) + ); + } + + #[tokio::test] + async fn test_search_filters_custom_object_class() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with(eq(Some(UserRequestFilter::from(true))), eq(false)) + .times(1) + .return_once(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob_1"), + ..Default::default() + }, + groups: None, + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::Equality("objectClass".to_owned(), "CUSTOMuserCLASS".to_owned()), + vec!["objectclass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), + attributes: vec![LdapPartialAttribute { + atype: "objectclass".to_string(), + vals: vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + b"customUserClass".to_vec(), + ] + },] + }), + make_search_success() + ]) + ); + } + + #[tokio::test] + async fn test_search_both() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().times(1).return_once(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob_1"), + email: "bob@bobmail.bob".into(), + display_name: Some("Bôb Böbberson".to_string()), + attributes: vec![ + Attribute { + name: "first_name".into(), + value: "Bôb".to_string().into(), + }, + Attribute { + name: "last_name".to_string().into(), + value: "Böbberson".to_string().into(), + }, + ], + ..Default::default() + }, + groups: None, + }]) + }); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::And(Vec::new())))) + .times(1) + .return_once(|_| { + Ok(vec![Group { + id: GroupId(1), + display_name: "group_1".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob"), UserId::new("john")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_search_request( + "dc=example,dc=com", + LdapFilter::And(vec![]), + vec!["objectClass", "dn", "cn"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec!["Bôb Böbberson".to_string().into_bytes()] + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + b"customUserClass".to_vec(), + ], + }, + ], + }), + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"group_1".to_vec()] + }, + LdapPartialAttribute { + atype: "objectClass".to_string(), + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec(),], + }, + ], + }), + make_search_success(), + ]) + ); + } + + #[tokio::test] + async fn test_search_wildcards() { + let mut mock = MockTestBackendHandler::new(); + + mock.expect_list_users().returning(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("bob_1"), + email: "bob@bobmail.bob".into(), + display_name: Some("Bôb Böbberson".to_string()), + attributes: vec![ + Attribute { + name: "avatar".into(), + value: JpegPhoto::for_tests().into(), + }, + Attribute { + name: "last_name".into(), + value: "Böbberson".to_string().into(), + }, + ], + uuid: uuid!("b4ac75e0-2900-3e21-926c-2f732c26b3fc"), + ..Default::default() + }, + groups: None, + }]) + }); + mock.expect_list_groups() + .with(eq(Some(GroupRequestFilter::And(Vec::new())))) + .returning(|_| { + Ok(vec![Group { + id: GroupId(1), + display_name: "group_1".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob"), UserId::new("john")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), + }]) + }); + let ldap_handler = setup_bound_admin_handler(mock).await; + + // Test simple wildcard + let request = + make_search_request("dc=example,dc=com", LdapFilter::And(vec![]), vec!["*", "+"]); + + // all: "objectclass", "dn", "uid", "mail", "givenname", "sn", "cn" + // Operational: "createtimestamp" + + let expected_result = Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "avatar".to_string(), + vals: vec![JpegPhoto::for_tests().into_bytes()], + }, + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec!["Bôb Böbberson".to_string().into_bytes()], + }, + LdapPartialAttribute { + atype: "createtimestamp".to_string(), + vals: vec![ + chrono::Utc + .timestamp_opt(0, 0) + .unwrap() + .to_rfc3339() + .into_bytes(), + ], + }, + LdapPartialAttribute { + atype: "entryuuid".to_string(), + vals: vec![b"b4ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()], + }, + LdapPartialAttribute { + atype: "jpegPhoto".to_string(), + vals: vec![JpegPhoto::for_tests().into_bytes()], + }, + LdapPartialAttribute { + atype: "last_name".to_string(), + vals: vec!["Böbberson".to_string().into_bytes()], + }, + LdapPartialAttribute { + atype: "mail".to_string(), + vals: vec![b"bob@bobmail.bob".to_vec()], + }, + LdapPartialAttribute { + atype: "objectclass".to_string(), + vals: vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + b"customUserClass".to_vec(), + ], + }, + LdapPartialAttribute { + atype: "sn".to_string(), + vals: vec!["Böbberson".to_string().into_bytes()], + }, + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec![b"bob_1".to_vec()], + }, + ], + }), + // "objectclass", "dn", "uid", "cn", "member", "uniquemember" + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group_1,ou=groups,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "cn".to_string(), + vals: vec![b"group_1".to_vec()], + }, + LdapPartialAttribute { + atype: "entryuuid".to_string(), + vals: vec![b"04ac75e0-2900-3e21-926c-2f732c26b3fc".to_vec()], + }, + //member / uniquemember : "uid={},ou=people,{}" + LdapPartialAttribute { + atype: "member".to_string(), + vals: vec![ + b"uid=bob,ou=people,dc=example,dc=com".to_vec(), + b"uid=john,ou=people,dc=example,dc=com".to_vec(), + ], + }, + LdapPartialAttribute { + atype: "objectclass".to_string(), + vals: vec![b"groupOfUniqueNames".to_vec(), b"groupOfNames".to_vec()], + }, + // UID + LdapPartialAttribute { + atype: "uid".to_string(), + vals: vec![b"group_1".to_vec()], + }, + LdapPartialAttribute { + atype: "uniquemember".to_string(), + vals: vec![ + b"uid=bob,ou=people,dc=example,dc=com".to_vec(), + b"uid=john,ou=people,dc=example,dc=com".to_vec(), + ], + }, + ], + }), + make_search_success(), + ]); + + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + expected_result + ); + + let request2 = make_search_request( + "dc=example,dc=com", + LdapFilter::And(vec![]), + vec!["objectclass", "obJEctclaSS", "dn", "*", "*"], + ); + + assert_eq!( + ldap_handler.do_search_or_dse(&request2).await, + expected_result + ); + + let request3 = make_search_request( + "dc=example,dc=com", + LdapFilter::And(vec![]), + vec!["*", "+", "+"], + ); + + assert_eq!( + ldap_handler.do_search_or_dse(&request3).await, + expected_result + ); + + let request4 = + make_search_request("dc=example,dc=com", LdapFilter::And(vec![]), vec![""; 0]); + + assert_eq!( + ldap_handler.do_search_or_dse(&request4).await, + expected_result + ); + + let request5 = make_search_request( + "dc=example,dc=com", + LdapFilter::And(vec![]), + vec!["objectclass", "dn", "uid", "*"], + ); + + assert_eq!( + ldap_handler.do_search_or_dse(&request5).await, + expected_result + ); + } + + #[tokio::test] + async fn test_search_wrong_base() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let request = make_search_request( + "ou=users,dc=example,dc=com", + LdapFilter::And(vec![]), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]) + ); + } + + #[tokio::test] + async fn test_search_unsupported_filters() { + let ldap_handler = setup_bound_admin_handler(MockTestBackendHandler::new()).await; + let request = make_user_search_request( + LdapFilter::Approx("uid".to_owned(), "value".to_owned()), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Err(LdapError { + code: LdapResultCode::UnwillingToPerform, + message: r#"Unsupported user filter: Approx("uid", "value")"#.to_string() + }) + ); + } + + #[tokio::test] + async fn test_search_filter_non_attribute() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users() + .with(eq(Some(true.into())), eq(false)) + .times(1) + .return_once(|_, _| Ok(vec![])); + let ldap_handler = setup_bound_admin_handler(mock).await; + let request = make_user_search_request( + LdapFilter::Present("displayname".to_owned()), + vec!["objectClass"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![make_search_success()]) + ); + } + + #[tokio::test] + async fn test_user_ou_search() { + let ldap_handler = setup_bound_readonly_handler(MockTestBackendHandler::new()).await; + let request = LdapSearchRequest { + base: "ou=people,dc=example,dc=com".to_owned(), + scope: LdapSearchScope::Base, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter: LdapFilter::And(vec![]), + attrs: Vec::new(), + }; + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "ou=people,dc=example,dc=com".to_owned(), + attributes: vec![LdapPartialAttribute { + atype: "objectClass".to_owned(), + vals: vec![b"top".to_vec(), b"organizationalUnit".to_vec()] + }] + }), + make_search_success() + ]) + ); + } + + #[tokio::test] + async fn test_custom_attribute_read() { + let mut mock = MockTestBackendHandler::new(); + mock.expect_list_users().times(1).return_once(|_, _| { + Ok(vec![UserAndGroups { + user: User { + user_id: UserId::new("test"), + attributes: vec![Attribute { + name: "nickname".into(), + value: "Bob the Builder".to_string().into(), + }], + ..Default::default() + }, + groups: None, + }]) + }); + mock.expect_list_groups().times(1).return_once(|_| { + Ok(vec![Group { + id: GroupId(1), + display_name: "group".into(), + creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), + users: vec![UserId::new("bob")], + uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: vec![Attribute { + name: "club_name".into(), + value: "Breakfast Club".to_string().into(), + }], + }]) + }); + mock.expect_get_schema().returning(|| { + Ok(Schema { + user_attributes: AttributeList { + attributes: vec![AttributeSchema { + name: "nickname".into(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: false, + is_readonly: false, + }], + }, + group_attributes: AttributeList { + attributes: vec![AttributeSchema { + name: "club_name".into(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: false, + is_readonly: false, + }], + }, + extra_user_object_classes: vec![ + LdapObjectClass::from("customUserClass"), + LdapObjectClass::from("myUserClass"), + ], + extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")], + }) + }); + let ldap_handler = setup_bound_readonly_handler(mock).await; + + let request = make_search_request( + "dc=example,dc=com", + LdapFilter::And(vec![]), + vec!["uid", "nickname", "club_name"], + ); + assert_eq!( + ldap_handler.do_search_or_dse(&request).await, + Ok(vec![ + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "uid=test,ou=people,dc=example,dc=com".to_string(), + attributes: vec![ + LdapPartialAttribute { + atype: "nickname".to_owned(), + vals: vec![b"Bob the Builder".to_vec()], + }, + LdapPartialAttribute { + atype: "uid".to_owned(), + vals: vec![b"test".to_vec()], + }, + ], + }), + LdapOp::SearchResultEntry(LdapSearchResultEntry { + dn: "cn=group,ou=groups,dc=example,dc=com".to_owned(), + attributes: vec![ + LdapPartialAttribute { + atype: "club_name".to_owned(), + vals: vec![b"Breakfast Club".to_vec()], + }, + LdapPartialAttribute { + atype: "uid".to_owned(), + vals: vec![b"group".to_vec()], + }, + ], + }), + make_search_success() + ]), + ); + } +} diff --git a/crates/sql-backend-handler/src/sql_group_backend_handler.rs b/crates/sql-backend-handler/src/sql_group_backend_handler.rs index 271b5eb..23e71f3 100644 --- a/crates/sql-backend-handler/src/sql_group_backend_handler.rs +++ b/crates/sql-backend-handler/src/sql_group_backend_handler.rs @@ -268,10 +268,12 @@ impl SqlBackendHandler { .display_name .as_ref() .map(|s| s.as_str().to_lowercase()); + let now = chrono::Utc::now().naive_utc(); let update_group = model::groups::ActiveModel { group_id: Set(request.group_id), display_name: request.display_name.map(Set).unwrap_or_default(), lowercase_display_name: lower_display_name.map(Set).unwrap_or_default(), + modified_date: Set(now), ..Default::default() }; update_group.update(transaction).await?; diff --git a/crates/sql-backend-handler/src/sql_migrations.rs b/crates/sql-backend-handler/src/sql_migrations.rs index eab8e6f..08a4c1b 100644 --- a/crates/sql-backend-handler/src/sql_migrations.rs +++ b/crates/sql-backend-handler/src/sql_migrations.rs @@ -27,6 +27,8 @@ pub enum Users { TotpSecret, MfaType, Uuid, + ModifiedDate, + PasswordModifiedDate, } #[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)] @@ -37,6 +39,7 @@ pub(crate) enum Groups { LowercaseDisplayName, CreationDate, Uuid, + ModifiedDate, } #[derive(DeriveIden, Clone, Copy)] @@ -1112,6 +1115,142 @@ async fn migrate_to_v10(transaction: DatabaseTransaction) -> Result Result { + let builder = transaction.get_database_backend(); + // Add modified_date to users table + transaction + .execute( + builder.build( + Table::alter() + .table(Users::Table) + .add_column( + ColumnDef::new(Users::ModifiedDate) + .date_time() + .not_null() + .default("CURRENT_TIMESTAMP"), + ), + ), + ) + .await?; + + // Add password_modified_date to users table + transaction + .execute( + builder.build( + Table::alter() + .table(Users::Table) + .add_column( + ColumnDef::new(Users::PasswordModifiedDate) + .date_time() + .not_null() + .default("CURRENT_TIMESTAMP"), + ), + ), + ) + .await?; + + // Add modified_date to groups table + transaction + .execute( + builder.build( + Table::alter() + .table(Groups::Table) + .add_column( + ColumnDef::new(Groups::ModifiedDate) + .date_time() + .not_null() + .default("CURRENT_TIMESTAMP"), + ), + ), + ) + .await?; + + // Initialize existing users with modified_date = creation_date + transaction + .execute( + builder.build( + Query::update() + .table(Users::Table) + .value(Users::ModifiedDate, Expr::col(Users::CreationDate)) + .value(Users::PasswordModifiedDate, Expr::col(Users::CreationDate)), + ), + ) + .await?; + + // Initialize existing groups with modified_date = creation_date + transaction + .execute( + builder.build( + Query::update() + .table(Groups::Table) + .value(Groups::ModifiedDate, Expr::col(Groups::CreationDate)), + ), + ) + .await?; + + // Add the new timestamp attributes to the user attribute schema as hardcoded read-only attributes + transaction + .execute( + builder.build( + Query::insert() + .into_table(UserAttributeSchema::Table) + .columns([ + UserAttributeSchema::UserAttributeSchemaName, + UserAttributeSchema::UserAttributeSchemaType, + UserAttributeSchema::UserAttributeSchemaIsList, + UserAttributeSchema::UserAttributeSchemaIsUserVisible, + UserAttributeSchema::UserAttributeSchemaIsUserEditable, + UserAttributeSchema::UserAttributeSchemaIsHardcoded, + ]) + .values_panic([ + "modified_date".into(), + AttributeType::DateTime.into(), + false.into(), + true.into(), + false.into(), + true.into(), + ]) + .values_panic([ + "password_modified_date".into(), + AttributeType::DateTime.into(), + false.into(), + true.into(), + false.into(), + true.into(), + ]), + ), + ) + .await?; + + // Add the new timestamp attribute to the group attribute schema as hardcoded read-only attribute + transaction + .execute( + builder.build( + Query::insert() + .into_table(GroupAttributeSchema::Table) + .columns([ + GroupAttributeSchema::GroupAttributeSchemaName, + GroupAttributeSchema::GroupAttributeSchemaType, + GroupAttributeSchema::GroupAttributeSchemaIsList, + GroupAttributeSchema::GroupAttributeSchemaIsGroupVisible, + GroupAttributeSchema::GroupAttributeSchemaIsGroupEditable, + GroupAttributeSchema::GroupAttributeSchemaIsHardcoded, + ]) + .values_panic([ + "modified_date".into(), + AttributeType::DateTime.into(), + false.into(), + true.into(), + false.into(), + true.into(), + ]), + ), + ) + .await?; + + Ok(transaction) +} + // This is needed to make an array of async functions. macro_rules! to_sync { ($l:ident) => { @@ -1142,6 +1281,7 @@ pub(crate) async fn migrate_from_version( to_sync!(migrate_to_v8), to_sync!(migrate_to_v9), to_sync!(migrate_to_v10), + to_sync!(migrate_to_v11), ]; assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize); for migration in 2..=last_version.0 { diff --git a/crates/sql-backend-handler/src/sql_opaque_handler.rs b/crates/sql-backend-handler/src/sql_opaque_handler.rs index a1c57ba..0aff1d3 100644 --- a/crates/sql-backend-handler/src/sql_opaque_handler.rs +++ b/crates/sql-backend-handler/src/sql_opaque_handler.rs @@ -197,9 +197,11 @@ impl OpaqueHandler for SqlOpaqueHandler { let password_file = opaque::server::registration::get_password_file(request.registration_upload); // Set the user password to the new password. + let now = chrono::Utc::now().naive_utc(); let user_update = model::users::ActiveModel { user_id: ActiveValue::Set(username.clone()), password_hash: ActiveValue::Set(Some(password_file.serialize())), + password_modified_date: ActiveValue::Set(now), ..Default::default() }; user_update.update(&self.sql_pool).await?; diff --git a/crates/sql-backend-handler/src/sql_tables.rs b/crates/sql-backend-handler/src/sql_tables.rs index e1c4365..2834fa2 100644 --- a/crates/sql-backend-handler/src/sql_tables.rs +++ b/crates/sql-backend-handler/src/sql_tables.rs @@ -9,7 +9,7 @@ pub type DbConnection = sea_orm::DatabaseConnection; #[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)] pub struct SchemaVersion(pub i16); -pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(10); +pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(11); #[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)] pub struct PrivateKeyHash(pub [u8; 32]); diff --git a/crates/sql-backend-handler/src/sql_user_backend_handler.rs b/crates/sql-backend-handler/src/sql_user_backend_handler.rs index 351eb6e..580426b 100644 --- a/crates/sql-backend-handler/src/sql_user_backend_handler.rs +++ b/crates/sql-backend-handler/src/sql_user_backend_handler.rs @@ -190,11 +190,13 @@ impl SqlBackendHandler { request: UpdateUserRequest, ) -> Result<()> { let lower_email = request.email.as_ref().map(|s| s.as_str().to_lowercase()); + let now = chrono::Utc::now().naive_utc(); let update_user = model::users::ActiveModel { user_id: ActiveValue::Set(request.user_id.clone()), email: request.email.map(ActiveValue::Set).unwrap_or_default(), lowercase_email: lower_email.map(ActiveValue::Set).unwrap_or_default(), display_name: to_value(&request.display_name), + modified_date: ActiveValue::Set(now), ..Default::default() }; let mut update_user_attributes = Vec::new();