From 646fe32645d33f67bfde7b29da453f5b656e18a8 Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Mon, 5 Feb 2024 22:20:08 +0100 Subject: [PATCH] server: Add support for custom LDAP object classes for users and groups --- schema.graphql | 1 + server/src/domain/handler.rs | 10 +- server/src/domain/ldap/group.rs | 12 +- server/src/domain/ldap/user.rs | 22 +++- .../src/domain/model/group_object_classes.rs | 23 ++++ server/src/domain/model/mod.rs | 4 +- server/src/domain/model/prelude.rs | 4 + .../src/domain/model/user_object_classes.rs | 23 ++++ server/src/domain/sql_migrations.rs | 60 +++++++++ .../src/domain/sql_schema_backend_handler.rs | 116 +++++++++++++++++- server/src/domain/sql_tables.rs | 2 +- server/src/domain/types.rs | 2 + server/src/infra/graphql/query.rs | 49 ++++++-- server/src/infra/ldap_handler.rs | 18 ++- server/src/infra/test_utils.rs | 6 + 15 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 server/src/domain/model/group_object_classes.rs create mode 100644 server/src/domain/model/user_object_classes.rs diff --git a/schema.graphql b/schema.graphql index 2824491..d3c501a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -162,6 +162,7 @@ enum AttributeType { type AttributeList { attributes: [AttributeSchema!]! + extraLdapObjectClasses: [String!]! } type Success { diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs index f218f1d..06ee879 100644 --- a/server/src/domain/handler.rs +++ b/server/src/domain/handler.rs @@ -2,7 +2,8 @@ use crate::domain::{ error::Result, types::{ AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId, - GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid, + GroupName, JpegPhoto, LdapObjectClass, Serialized, User, UserAndGroups, UserColumn, UserId, + Uuid, }, }; use async_trait::async_trait; @@ -175,6 +176,8 @@ impl AttributeList { pub struct Schema { pub user_attributes: AttributeList, pub group_attributes: AttributeList, + pub extra_user_object_classes: Vec, + pub extra_group_object_classes: Vec, } #[async_trait] @@ -227,6 +230,11 @@ pub trait SchemaBackendHandler: ReadSchemaBackendHandler { // Note: It's up to the caller to make sure that the attribute is not hardcoded. async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>; async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>; + + async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>; + async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>; + async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>; + async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>; } #[async_trait] diff --git a/server/src/domain/ldap/group.rs b/server/src/domain/ldap/group.rs index b41fc19..b481a7c 100644 --- a/server/src/domain/ldap/group.rs +++ b/server/src/domain/ldap/group.rs @@ -30,7 +30,17 @@ pub fn get_group_attribute( ) -> Option>> { let attribute = AttributeName::from(attribute); let attribute_values = match map_group_field(&attribute, schema) { - GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()], + GroupFieldType::ObjectClass => { + let mut classes = vec![b"groupOfUniqueNames".to_vec()]; + classes.extend( + schema + .get_schema() + .extra_group_object_classes + .iter() + .map(|c| c.as_str().as_bytes().to_vec()), + ); + classes + } // Always returned as part of the base response. GroupFieldType::Dn => return None, GroupFieldType::EntryDn => { diff --git a/server/src/domain/ldap/user.rs b/server/src/domain/ldap/user.rs index 925e888..b381ec4 100644 --- a/server/src/domain/ldap/user.rs +++ b/server/src/domain/ldap/user.rs @@ -28,12 +28,22 @@ pub fn get_user_attribute( ) -> Option>> { let attribute = AttributeName::from(attribute); let attribute_values = match map_user_field(&attribute, schema) { - UserFieldType::ObjectClass => vec![ - b"inetOrgPerson".to_vec(), - b"posixAccount".to_vec(), - b"mailAccount".to_vec(), - b"person".to_vec(), - ], + UserFieldType::ObjectClass => { + let mut classes = vec![ + b"inetOrgPerson".to_vec(), + b"posixAccount".to_vec(), + b"mailAccount".to_vec(), + b"person".to_vec(), + ]; + classes.extend( + schema + .get_schema() + .extra_user_object_classes + .iter() + .map(|c| c.as_str().as_bytes().to_vec()), + ); + classes + } // dn is always returned as part of the base response. UserFieldType::Dn => return None, UserFieldType::EntryDn => { diff --git a/server/src/domain/model/group_object_classes.rs b/server/src/domain/model/group_object_classes.rs new file mode 100644 index 0000000..2f653c3 --- /dev/null +++ b/server/src/domain/model/group_object_classes.rs @@ -0,0 +1,23 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::domain::types::LdapObjectClass; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "group_object_classes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub lower_object_class: String, + pub object_class: LdapObjectClass, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl From for LdapObjectClass { + fn from(value: Model) -> Self { + value.object_class + } +} diff --git a/server/src/domain/model/mod.rs b/server/src/domain/model/mod.rs index 622f478..9dff439 100644 --- a/server/src/domain/model/mod.rs +++ b/server/src/domain/model/mod.rs @@ -1,5 +1,3 @@ -//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3 - pub mod prelude; pub mod groups; @@ -11,8 +9,10 @@ pub mod users; pub mod user_attribute_schema; pub mod user_attributes; +pub mod user_object_classes; pub mod group_attribute_schema; pub mod group_attributes; +pub mod group_object_classes; pub use prelude::*; diff --git a/server/src/domain/model/prelude.rs b/server/src/domain/model/prelude.rs index 337b85b..8f64063 100644 --- a/server/src/domain/model/prelude.rs +++ b/server/src/domain/model/prelude.rs @@ -4,6 +4,8 @@ pub use super::group_attribute_schema::Column as GroupAttributeSchemaColumn; pub use super::group_attribute_schema::Entity as GroupAttributeSchema; pub use super::group_attributes::Column as GroupAttributesColumn; pub use super::group_attributes::Entity as GroupAttributes; +pub use super::group_object_classes::Column as GroupObjectClassesColumn; +pub use super::group_object_classes::Entity as GroupObjectClasses; pub use super::groups::Column as GroupColumn; pub use super::groups::Entity as Group; pub use super::jwt_refresh_storage::Column as JwtRefreshStorageColumn; @@ -18,5 +20,7 @@ pub use super::user_attribute_schema::Column as UserAttributeSchemaColumn; pub use super::user_attribute_schema::Entity as UserAttributeSchema; pub use super::user_attributes::Column as UserAttributesColumn; pub use super::user_attributes::Entity as UserAttributes; +pub use super::user_object_classes::Column as UserObjectClassesColumn; +pub use super::user_object_classes::Entity as UserObjectClasses; pub use super::users::Column as UserColumn; pub use super::users::Entity as User; diff --git a/server/src/domain/model/user_object_classes.rs b/server/src/domain/model/user_object_classes.rs new file mode 100644 index 0000000..9bdfe97 --- /dev/null +++ b/server/src/domain/model/user_object_classes.rs @@ -0,0 +1,23 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::domain::types::LdapObjectClass; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "user_object_classes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub lower_object_class: String, + pub object_class: LdapObjectClass, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl From for LdapObjectClass { + fn from(value: Model) -> Self { + value.object_class + } +} diff --git a/server/src/domain/sql_migrations.rs b/server/src/domain/sql_migrations.rs index 02f64b0..587971e 100644 --- a/server/src/domain/sql_migrations.rs +++ b/server/src/domain/sql_migrations.rs @@ -88,6 +88,20 @@ pub enum GroupAttributes { GroupAttributeValue, } +#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)] +pub enum UserObjectClasses { + Table, + LowerObjectClass, + ObjectClass, +} + +#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)] +pub enum GroupObjectClasses { + Table, + LowerObjectClass, + ObjectClass, +} + // Metadata about the SQL DB. #[derive(DeriveIden)] pub enum Metadata { @@ -1031,6 +1045,51 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result Result { + let builder = transaction.get_database_backend(); + transaction + .execute( + builder.build( + Table::create() + .table(UserObjectClasses::Table) + .if_not_exists() + .col( + ColumnDef::new(UserObjectClasses::LowerObjectClass) + .string_len(255) + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(UserObjectClasses::ObjectClass) + .string_len(255) + .not_null(), + ), + ), + ) + .await?; + transaction + .execute( + builder.build( + Table::create() + .table(GroupObjectClasses::Table) + .if_not_exists() + .col( + ColumnDef::new(GroupObjectClasses::LowerObjectClass) + .string_len(255) + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(GroupObjectClasses::ObjectClass) + .string_len(255) + .not_null(), + ), + ), + ) + .await?; + Ok(transaction) +} + // This is needed to make an array of async functions. macro_rules! to_sync { ($l:ident) => { @@ -1059,6 +1118,7 @@ pub async fn migrate_from_version( to_sync!(migrate_to_v6), to_sync!(migrate_to_v7), to_sync!(migrate_to_v8), + to_sync!(migrate_to_v9), ]; assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize); for migration in 2..=last_version.0 { diff --git a/server/src/domain/sql_schema_backend_handler.rs b/server/src/domain/sql_schema_backend_handler.rs index 444a6ba..c52f998 100644 --- a/server/src/domain/sql_schema_backend_handler.rs +++ b/server/src/domain/sql_schema_backend_handler.rs @@ -6,7 +6,7 @@ use crate::domain::{ }, model, sql_backend_handler::SqlBackendHandler, - types::AttributeName, + types::{AttributeName, LdapObjectClass}, }; use async_trait::async_trait; use sea_orm::{ @@ -66,6 +66,44 @@ impl SchemaBackendHandler for SqlBackendHandler { .await?; Ok(()) } + + async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> { + let mut name_key = name.to_string(); + name_key.make_ascii_lowercase(); + model::user_object_classes::ActiveModel { + lower_object_class: Set(name_key), + object_class: Set(name.clone()), + } + .insert(&self.sql_pool) + .await?; + Ok(()) + } + + async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> { + let mut name_key = name.to_string(); + name_key.make_ascii_lowercase(); + model::group_object_classes::ActiveModel { + lower_object_class: Set(name_key), + object_class: Set(name.clone()), + } + .insert(&self.sql_pool) + .await?; + Ok(()) + } + + async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> { + model::UserObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase()) + .exec(&self.sql_pool) + .await?; + Ok(()) + } + + async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> { + model::GroupObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase()) + .exec(&self.sql_pool) + .await?; + Ok(()) + } } impl SqlBackendHandler { @@ -79,6 +117,8 @@ impl SqlBackendHandler { group_attributes: AttributeList { attributes: Self::get_group_attributes(transaction).await?, }, + extra_user_object_classes: Self::get_user_object_classes(transaction).await?, + extra_group_object_classes: Self::get_group_object_classes(transaction).await?, }) } @@ -105,6 +145,30 @@ impl SqlBackendHandler { .map(|m| m.into()) .collect()) } + + async fn get_user_object_classes( + transaction: &DatabaseTransaction, + ) -> Result> { + Ok(model::UserObjectClasses::find() + .order_by_asc(model::UserObjectClassesColumn::ObjectClass) + .all(transaction) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + async fn get_group_object_classes( + transaction: &DatabaseTransaction, + ) -> Result> { + Ok(model::GroupObjectClasses::find() + .order_by_asc(model::GroupObjectClassesColumn::ObjectClass) + .all(transaction) + .await? + .into_iter() + .map(Into::into) + .collect()) + } } #[cfg(test)] @@ -151,7 +215,9 @@ mod tests { }, group_attributes: AttributeList { attributes: Vec::new() - } + }, + extra_user_object_classes: Vec::new(), + extra_group_object_classes: Vec::new(), } ); } @@ -247,4 +313,50 @@ mod tests { .attributes .contains(&expected_value)); } + + #[tokio::test] + async fn test_user_object_class_add_and_delete() { + let fixture = TestFixture::new().await; + let new_object_class = LdapObjectClass::new("newObjectClass"); + fixture + .handler + .add_user_object_class(&new_object_class) + .await + .unwrap(); + assert_eq!( + fixture + .handler + .get_schema() + .await + .unwrap() + .extra_user_object_classes, + vec![new_object_class.clone()] + ); + fixture + .handler + .add_user_object_class(&LdapObjectClass::new("newobjEctclass")) + .await + .expect_err("Should not be able to add the same object class twice"); + assert_eq!( + fixture + .handler + .get_schema() + .await + .unwrap() + .extra_user_object_classes, + vec![new_object_class.clone()] + ); + fixture + .handler + .delete_user_object_class(&new_object_class) + .await + .unwrap(); + assert!(fixture + .handler + .get_schema() + .await + .unwrap() + .extra_user_object_classes + .is_empty()); + } } diff --git a/server/src/domain/sql_tables.rs b/server/src/domain/sql_tables.rs index 0f518a0..9b50d51 100644 --- a/server/src/domain/sql_tables.rs +++ b/server/src/domain/sql_tables.rs @@ -11,7 +11,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(8); +pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(9); #[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)] pub struct PrivateKeyHash(pub [u8; 32]); diff --git a/server/src/domain/types.rs b/server/src/domain/types.rs index 1f498e8..2d7d928 100644 --- a/server/src/domain/types.rs +++ b/server/src/domain/types.rs @@ -271,6 +271,8 @@ impl TryFromU64 for AttributeName { )) } } + +make_case_insensitive_comparable_string!(LdapObjectClass); make_case_insensitive_comparable_string!(Email); make_case_insensitive_comparable_string!(GroupName); diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs index ae4e7d9..4e34fce 100644 --- a/server/src/infra/graphql/query.rs +++ b/server/src/infra/graphql/query.rs @@ -7,7 +7,7 @@ use crate::{ ldap::utils::{map_user_field, UserFieldType}, model::UserColumn, schema::PublicSchema, - types::{AttributeType, GroupDetails, GroupId, JpegPhoto, UserId}, + types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId}, }, infra::{ access_control::{ReadonlyBackendHandler, UserReadableBackendHandler}, @@ -523,26 +523,32 @@ impl From for AttributeSchema { - schema: DomainAttributeList, + attributes: DomainAttributeList, + extra_classes: Vec, _phantom: std::marker::PhantomData>, } #[graphql_object(context = Context)] impl AttributeList { fn attributes(&self) -> Vec> { - self.schema + self.attributes .attributes .clone() .into_iter() .map(Into::into) .collect() } + + fn extra_ldap_object_classes(&self) -> Vec { + self.extra_classes.iter().map(|c| c.to_string()).collect() + } } -impl From for AttributeList { - fn from(value: DomainAttributeList) -> Self { +impl AttributeList { + fn new(attributes: DomainAttributeList, extra_classes: Vec) -> Self { Self { - schema: value, + attributes, + extra_classes, _phantom: std::marker::PhantomData, } } @@ -557,10 +563,16 @@ pub struct Schema { #[graphql_object(context = Context)] impl Schema { fn user_schema(&self) -> AttributeList { - self.schema.get_schema().user_attributes.clone().into() + AttributeList::::new( + self.schema.get_schema().user_attributes.clone(), + self.schema.get_schema().extra_user_object_classes.clone(), + ) } fn group_schema(&self) -> AttributeList { - self.schema.get_schema().group_attributes.clone().into() + AttributeList::::new( + self.schema.get_schema().group_attributes.clone(), + self.schema.get_schema().extra_group_object_classes.clone(), + ) } } @@ -670,7 +682,7 @@ mod tests { use crate::{ domain::{ handler::AttributeList, - types::{AttributeName, AttributeType, Serialized}, + types::{AttributeName, AttributeType, LdapObjectClass, Serialized}, }, infra::{ access_control::{Permission, ValidationResults}, @@ -755,6 +767,11 @@ mod tests { is_hardcoded: false, }], }, + extra_user_object_classes: vec![ + LdapObjectClass::from("customUserClass"), + LdapObjectClass::from("myUserClass"), + ], + extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")], }) }); mock.expect_get_user_details() @@ -946,6 +963,7 @@ mod tests { isEditable isHardcoded } + extraLdapObjectClasses } groupSchema { attributes { @@ -956,6 +974,7 @@ mod tests { isEditable isHardcoded } + extraLdapObjectClasses } } }"#; @@ -1040,7 +1059,8 @@ mod tests { "isEditable": false, "isHardcoded": true, }, - ] + ], + "extraLdapObjectClasses": ["customUserClass"], }, "groupSchema": { "attributes": [ @@ -1076,7 +1096,8 @@ mod tests { "isEditable": false, "isHardcoded": true, }, - ] + ], + "extraLdapObjectClasses": [], } } }), @@ -1093,6 +1114,7 @@ mod tests { attributes { name } + extraLdapObjectClasses } } }"#; @@ -1114,6 +1136,8 @@ mod tests { group_attributes: AttributeList { attributes: Vec::new(), }, + extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")], + extra_group_object_classes: Vec::new(), }) }); @@ -1139,7 +1163,8 @@ mod tests { {"name": "mail"}, {"name": "user_id"}, {"name": "uuid"}, - ] + ], + "extraLdapObjectClasses": ["customUserClass"], } } } ), diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 0e96e9a..8fe88fe 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -1290,7 +1290,8 @@ mod tests { b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), - b"person".to_vec() + b"person".to_vec(), + b"customUserClass".to_vec(), ] }, LdapPartialAttribute { @@ -1332,7 +1333,8 @@ mod tests { b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), - b"person".to_vec() + b"person".to_vec(), + b"customUserClass".to_vec(), ] }, LdapPartialAttribute { @@ -1919,7 +1921,8 @@ mod tests { b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), - b"person".to_vec() + b"person".to_vec(), + b"customUserClass".to_vec(), ] },] }), @@ -1983,7 +1986,8 @@ mod tests { b"inetOrgPerson".to_vec(), b"posixAccount".to_vec(), b"mailAccount".to_vec(), - b"person".to_vec() + b"person".to_vec(), + b"customUserClass".to_vec(), ] }, LdapPartialAttribute { @@ -2068,6 +2072,7 @@ mod tests { b"posixAccount".to_vec(), b"mailAccount".to_vec(), b"person".to_vec(), + b"customUserClass".to_vec(), ], }, LdapPartialAttribute { @@ -2849,6 +2854,11 @@ mod tests { is_hardcoded: false, }], }, + extra_user_object_classes: vec![ + LdapObjectClass::from("customUserClass"), + LdapObjectClass::from("myUserClass"), + ], + extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")], }) }); let mut ldap_handler = setup_bound_readonly_handler(mock).await; diff --git a/server/src/infra/test_utils.rs b/server/src/infra/test_utils.rs index f204cee..1beb7c1 100644 --- a/server/src/infra/test_utils.rs +++ b/server/src/infra/test_utils.rs @@ -47,6 +47,10 @@ mockall::mock! { async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>; async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>; async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>; + async fn add_user_object_class(&self, request: &LdapObjectClass) -> Result<()>; + async fn add_group_object_class(&self, request: &LdapObjectClass) -> Result<()>; + async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>; + async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>; } #[async_trait] impl BackendHandler for TestBackendHandler {} @@ -102,6 +106,8 @@ pub fn setup_default_schema(mock: &mut MockTestBackendHandler) { group_attributes: AttributeList { attributes: Vec::new(), }, + extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")], + extra_group_object_classes: Vec::new(), }) }); }