diff --git a/schema.graphql b/schema.graphql index 4b80d3e..e2ac084 100644 --- a/schema.graphql +++ b/schema.graphql @@ -24,6 +24,8 @@ type Group { displayName: String! creationDate: DateTimeUtc! uuid: String! + "User-defined attributes." + attributes: [AttributeValue!]! "The groups to which this user belongs." users: [User!]! } @@ -83,6 +85,7 @@ type User { avatar: String creationDate: DateTimeUtc! uuid: String! + "User-defined attributes." attributes: [AttributeValue!]! "The groups to which this user belongs." groups: [Group!]! diff --git a/server/src/domain/model/group_attributes.rs b/server/src/domain/model/group_attributes.rs index a6325a1..2048fbb 100644 --- a/server/src/domain/model/group_attributes.rs +++ b/server/src/domain/model/group_attributes.rs @@ -1,7 +1,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -use crate::domain::types::{GroupId, Serialized}; +use crate::domain::types::{AttributeValue, GroupId, Serialized}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "group_attributes")] @@ -55,3 +55,18 @@ impl Related for Entity { } impl ActiveModelBehavior for ActiveModel {} + +impl From for AttributeValue { + fn from( + Model { + group_id: _, + attribute_name, + value, + }: Model, + ) -> Self { + Self { + name: attribute_name, + value, + } + } +} diff --git a/server/src/domain/model/groups.rs b/server/src/domain/model/groups.rs index d9a74c8..296109c 100644 --- a/server/src/domain/model/groups.rs +++ b/server/src/domain/model/groups.rs @@ -37,6 +37,7 @@ impl From for crate::domain::types::Group { creation_date: group.creation_date, uuid: group.uuid, users: vec![], + attributes: Vec::new(), } } } @@ -48,6 +49,7 @@ impl From for crate::domain::types::GroupDetails { display_name: group.display_name, creation_date: group.creation_date, uuid: group.uuid, + attributes: Vec::new(), } } } diff --git a/server/src/domain/sql_group_backend_handler.rs b/server/src/domain/sql_group_backend_handler.rs index a962c26..0752fcb 100644 --- a/server/src/domain/sql_group_backend_handler.rs +++ b/server/src/domain/sql_group_backend_handler.rs @@ -5,7 +5,7 @@ use crate::domain::{ }, model::{self, GroupColumn, MembershipColumn}, sql_backend_handler::SqlBackendHandler, - types::{Group, GroupDetails, GroupId, Uuid}, + types::{AttributeValue, Group, GroupDetails, GroupId, Uuid}, }; use async_trait::async_trait; use sea_orm::{ @@ -63,8 +63,7 @@ impl GroupListerBackendHandler for SqlBackendHandler { #[instrument(skip(self), level = "debug", ret, err)] async fn list_groups(&self, filters: Option) -> Result> { let results = model::Group::find() - // The order_by must be before find_with_related otherwise the primary order is by group_id. - .order_by_asc(GroupColumn::DisplayName) + .order_by_asc(GroupColumn::GroupId) .find_with_related(model::Membership) .filter( filters @@ -84,7 +83,7 @@ impl GroupListerBackendHandler for SqlBackendHandler { ) .all(&self.sql_pool) .await?; - Ok(results + let mut groups: Vec<_> = results .into_iter() .map(|(group, users)| { let users: Vec<_> = users.into_iter().map(|u| u.user_id).collect(); @@ -93,7 +92,30 @@ impl GroupListerBackendHandler for SqlBackendHandler { ..group.into() } }) - .collect()) + .collect(); + let group_ids = groups.iter().map(|u| &u.id); + let attributes = model::GroupAttributes::find() + .filter(model::GroupAttributesColumn::GroupId.is_in(group_ids)) + .order_by_asc(model::GroupAttributesColumn::GroupId) + .order_by_asc(model::GroupAttributesColumn::AttributeName) + .all(&self.sql_pool) + .await?; + let mut attributes_iter = attributes.into_iter().peekable(); + use itertools::Itertools; // For take_while_ref + for group in groups.iter_mut() { + assert!(attributes_iter + .peek() + .map(|u| u.group_id >= group.id) + .unwrap_or(true), + "Attributes are not sorted, groups are not sorted, or previous group didn't consume all the attributes"); + + group.attributes = attributes_iter + .take_while_ref(|u| u.group_id == group.id) + .map(AttributeValue::from) + .collect(); + } + groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name)); + Ok(groups) } } @@ -101,11 +123,18 @@ impl GroupListerBackendHandler for SqlBackendHandler { impl GroupBackendHandler for SqlBackendHandler { #[instrument(skip(self), level = "debug", ret, err)] async fn get_group_details(&self, group_id: GroupId) -> Result { - model::Group::find_by_id(group_id) + let mut group_details = model::Group::find_by_id(group_id) .one(&self.sql_pool) .await? .map(Into::::into) - .ok_or_else(|| DomainError::EntityNotFound(format!("{:?}", group_id))) + .ok_or_else(|| DomainError::EntityNotFound(format!("{:?}", group_id)))?; + let attributes = model::GroupAttributes::find() + .filter(model::GroupAttributesColumn::GroupId.eq(group_details.group_id)) + .order_by_asc(model::GroupAttributesColumn::AttributeName) + .all(&self.sql_pool) + .await?; + group_details.attributes = attributes.into_iter().map(AttributeValue::from).collect(); + Ok(group_details) } #[instrument(skip(self), level = "debug", err, fields(group_id = ?request.group_id))] diff --git a/server/src/domain/types.rs b/server/src/domain/types.rs index 52f0878..0e1eddb 100644 --- a/server/src/domain/types.rs +++ b/server/src/domain/types.rs @@ -63,7 +63,7 @@ macro_rules! uuid { }; } -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, DeriveValueType)] +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveValueType)] #[sea_orm(column_type = "Binary(BlobSize::Long)", array_type = "Bytes")] pub struct Serialized(Vec); @@ -283,7 +283,7 @@ impl IntoActiveValue for JpegPhoto { } } -#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Hash)] pub struct AttributeValue { pub name: String, pub value: Serialized, @@ -314,7 +314,19 @@ impl Default for User { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveValueType)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + DeriveValueType, +)] pub struct GroupId(pub i32); impl TryFromU64 for GroupId { @@ -323,6 +335,12 @@ impl TryFromU64 for GroupId { } } +impl From<&GroupId> for Value { + fn from(id: &GroupId) -> Self { + (*id).into() + } +} + #[derive( Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, EnumString, IntoStaticStr, )] @@ -375,6 +393,7 @@ pub struct Group { pub creation_date: NaiveDateTime, pub uuid: Uuid, pub users: Vec, + pub attributes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -383,6 +402,7 @@ pub struct GroupDetails { pub display_name: String, pub creation_date: NaiveDateTime, pub uuid: Uuid, + pub attributes: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs index 08fb8dc..20764e1 100644 --- a/server/src/infra/graphql/query.rs +++ b/server/src/infra/graphql/query.rs @@ -286,7 +286,8 @@ impl User { self.user.uuid.as_str() } - fn attributes(&self) -> Vec> { + /// User-defined attributes. + fn attributes(&self) -> Vec> { self.user .attributes .clone() @@ -344,6 +345,7 @@ pub struct Group { display_name: String, creation_date: chrono::NaiveDateTime, uuid: String, + attributes: Vec, members: Option>, _phantom: std::marker::PhantomData>, } @@ -362,6 +364,16 @@ impl Group { fn uuid(&self) -> String { self.uuid.clone() } + + /// User-defined attributes. + fn attributes(&self) -> Vec> { + self.attributes + .clone() + .into_iter() + .map(Into::into) + .collect() + } + /// The groups to which this user belongs. async fn users(&self, context: &Context) -> FieldResult>> { let span = debug_span!("[GraphQL query] group::users"); @@ -392,6 +404,7 @@ impl From for Group { display_name: group_details.display_name, creation_date: group_details.creation_date, uuid: group_details.uuid.into_string(), + attributes: group_details.attributes, members: None, _phantom: std::marker::PhantomData, } @@ -405,6 +418,7 @@ impl From for Group { display_name: group.display_name, creation_date: group.creation_date, uuid: group.uuid.into_string(), + attributes: group.attributes, members: Some(group.users.into_iter().map(UserId::into_string).collect()), _phantom: std::marker::PhantomData, } @@ -501,14 +515,37 @@ impl From for Schema { } } +trait SchemaAttributeExtractor: std::marker::Send { + fn get_attributes(schema: &DomainSchema) -> &DomainAttributeList; +} + +struct SchemaUserAttributeExtractor; + +impl SchemaAttributeExtractor for SchemaUserAttributeExtractor { + fn get_attributes(schema: &DomainSchema) -> &DomainAttributeList { + &schema.get_schema().user_attributes + } +} + +struct SchemaGroupAttributeExtractor; + +impl SchemaAttributeExtractor for SchemaGroupAttributeExtractor { + fn get_attributes(schema: &DomainSchema) -> &DomainAttributeList { + &schema.get_schema().group_attributes + } +} + #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -pub struct AttributeValue { +pub struct AttributeValue { attribute: DomainAttributeValue, _phantom: std::marker::PhantomData>, + _phantom_extractor: std::marker::PhantomData, } #[graphql_object(context = Context)] -impl AttributeValue { +impl + AttributeValue +{ fn name(&self) -> &str { &self.attribute.name } @@ -518,19 +555,17 @@ impl AttributeValue { .get_user_restricted_lister_handler(&context.validation_result); serialize_attribute( &self.attribute, - &PublicSchema::from(handler.get_schema().await?), + Extractor::get_attributes(&PublicSchema::from(handler.get_schema().await?)), ) } } pub fn serialize_attribute( attribute: &DomainAttributeValue, - schema: &DomainSchema, + attributes: &DomainAttributeList, ) -> FieldResult> { let convert_date = |date| chrono::Utc.from_utc_datetime(&date).to_rfc3339(); - schema - .get_schema() - .user_attributes + attributes .get_attribute_type(&attribute.name) .map(|attribute_type| { match attribute_type { @@ -575,11 +610,14 @@ pub fn serialize_attribute( .ok_or_else(|| FieldError::from(anyhow::anyhow!("Unknown attribute: {}", &attribute.name))) } -impl From for AttributeValue { +impl From + for AttributeValue +{ fn from(value: DomainAttributeValue) -> Self { Self { attribute: value, _phantom: std::marker::PhantomData, + _phantom_extractor: std::marker::PhantomData, } } } @@ -634,12 +672,49 @@ mod tests { displayName creationDate uuid + attributes { + name + value + } } } }"#; let mut mock = MockTestBackendHandler::new(); - setup_default_schema(&mut mock); + mock.expect_get_schema().returning(|| { + Ok(crate::domain::handler::Schema { + user_attributes: DomainAttributeList { + attributes: vec![ + DomainAttributeSchema { + name: "first_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + DomainAttributeSchema { + name: "last_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + ], + }, + group_attributes: DomainAttributeList { + attributes: vec![DomainAttributeSchema { + name: "club_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: false, + }], + }, + }) + }); mock.expect_get_user_details() .with(eq(UserId::new("bob"))) .return_once(|_| { @@ -667,12 +742,17 @@ mod tests { display_name: "Bobbersons".to_string(), creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(), uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: vec![DomainAttributeValue { + name: "club_name".to_owned(), + value: Serialized::from("Gang of Four"), + }], }); groups.insert(GroupDetails { group_id: GroupId(7), display_name: "Jefferees".to_string(), creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(), uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: Vec::new(), }); mock.expect_get_user_groups() .with(eq(UserId::new("bob"))) @@ -704,13 +784,19 @@ mod tests { "id": 3, "displayName": "Bobbersons", "creationDate": "1970-01-01T00:00:00.000000042+00:00", - "uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8" + "uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8", + "attributes": [{ + "name": "club_name", + "value": ["Gang of Four"], + }, + ], }, { "id": 7, "displayName": "Jefferees", "creationDate": "1970-01-01T00:00:00.000000012+00:00", - "uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8" + "uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8", + "attributes": [], }] } }), diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 6ce8f80..6f6af12 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -856,6 +856,7 @@ mod tests { display_name: group, creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: Vec::new(), }); Ok(set) }); @@ -942,6 +943,7 @@ mod tests { display_name: "lldap_admin".to_string(), creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: Vec::new(), }); Ok(set) }); @@ -1028,6 +1030,7 @@ mod tests { display_name: "rockstars".to_string(), creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: Vec::new(), }]), }]) }); @@ -1344,6 +1347,7 @@ mod tests { 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(), }, Group { id: GroupId(3), @@ -1351,6 +1355,7 @@ mod tests { 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(), }, ]) }); @@ -1442,6 +1447,7 @@ mod tests { creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), users: vec![], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), }]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; @@ -1512,6 +1518,7 @@ mod tests { creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), users: vec![], uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), + attributes: Vec::new(), }]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; @@ -1881,6 +1888,7 @@ mod tests { 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 mut ldap_handler = setup_bound_admin_handler(mock).await; @@ -1963,6 +1971,7 @@ mod tests { 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 mut ldap_handler = setup_bound_admin_handler(mock).await; @@ -2344,6 +2353,7 @@ mod tests { display_name: "lldap_admin".to_string(), creation_date: chrono::Utc.timestamp_opt(42, 42).unwrap().naive_utc(), uuid: uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), + attributes: Vec::new(), }); mock.expect_get_user_groups() .with(eq(UserId::new("bob"))) @@ -2571,6 +2581,7 @@ mod tests { 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::new(), }]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await; @@ -2664,6 +2675,7 @@ mod tests { 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::new(), }]) }); let mut ldap_handler = setup_bound_admin_handler(mock).await;