From c6ecf8d58a287e137cc81d5ae261032b85094c0a Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Sun, 22 Oct 2023 13:07:47 +0200 Subject: [PATCH] server: Add graphql support for setting attributes --- app/src/components/create_user.rs | 1 + app/src/components/user_details_form.rs | 2 + auth/Cargo.toml | 2 +- migration-tool/src/ldap.rs | 1 + schema.graphql | 46 ++++- server/src/domain/types.rs | 8 +- server/src/infra/access_control.rs | 9 +- server/src/infra/graphql/mutation.rs | 212 +++++++++++++++++++++--- server/tests/common/fixture.rs | 1 + 9 files changed, 244 insertions(+), 38 deletions(-) diff --git a/app/src/components/create_user.rs b/app/src/components/create_user.rs index 5c2bd57..ba74b80 100644 --- a/app/src/components/create_user.rs +++ b/app/src/components/create_user.rs @@ -90,6 +90,7 @@ impl CommonComponent for CreateUserForm { firstName: to_option(model.first_name), lastName: to_option(model.last_name), avatar: None, + attributes: None, }, }; self.common.call_graphql::( diff --git a/app/src/components/user_details_form.rs b/app/src/components/user_details_form.rs index 0339bc2..0f0f78e 100644 --- a/app/src/components/user_details_form.rs +++ b/app/src/components/user_details_form.rs @@ -391,6 +391,8 @@ impl UserDetailsForm { firstName: None, lastName: None, avatar: None, + removeAttributes: None, + insertAttributes: None, }; let default_user_input = user_input.clone(); let model = self.form.model(); diff --git a/auth/Cargo.toml b/auth/Cargo.toml index 6833cd9..4d53823 100644 --- a/auth/Cargo.toml +++ b/auth/Cargo.toml @@ -18,7 +18,7 @@ js = [] rust-argon2 = "0.8" curve25519-dalek = "3" digest = "0.9" -generic-array = "*" +generic-array = "0.14" rand = "0.8" serde = "*" sha2 = "0.9" diff --git a/migration-tool/src/ldap.rs b/migration-tool/src/ldap.rs index efa7122..52fbab2 100644 --- a/migration-tool/src/ldap.rs +++ b/migration-tool/src/ldap.rs @@ -194,6 +194,7 @@ impl TryFrom for User { first_name, last_name, avatar: avatar.map(base64::encode), + attributes: None, }, password, entry.dn, diff --git a/schema.graphql b/schema.graphql index 6e34296..a691c86 100644 --- a/schema.graphql +++ b/schema.graphql @@ -6,6 +6,7 @@ type AttributeValue { type Mutation { createUser(user: CreateUserInput!): User! createGroup(name: String!): Group! + createGroupWithDetails(request: CreateGroupInput!): Group! updateUser(user: UpdateUserInput!): Success! updateGroup(group: UpdateGroupInput!): Success! addUserToGroup(userId: String!, groupId: Int!): Success! @@ -61,7 +62,8 @@ input CreateUserInput { displayName: String firstName: String lastName: String - avatar: String + "Base64 encoded JpegPhoto." avatar: String + "User-defined attributes." attributes: [AttributeValueInput!] } type AttributeSchema { @@ -80,7 +82,15 @@ input UpdateUserInput { displayName: String firstName: String lastName: String - avatar: String + "Base64 encoded JpegPhoto." avatar: String + """ + Attribute names to remove. + They are processed before insertions. + """ removeAttributes: [String!] + """ + Inserts or updates the given attributes. + For lists, the entire list must be provided. + """ insertAttributes: [AttributeValueInput!] } input EqualityConstraint { @@ -95,8 +105,36 @@ type Schema { "The fields that can be updated for a group." input UpdateGroupInput { - id: Int! - displayName: String + "The group ID." id: Int! + "The new display name." displayName: String + """ + Attribute names to remove. + They are processed before insertions. + """ removeAttributes: [String!] + """ + Inserts or updates the given attributes. + For lists, the entire list must be provided. + """ insertAttributes: [AttributeValueInput!] +} + +input AttributeValueInput { + """ + The name of the attribute. It must be present in the schema, and the type informs how + to interpret the values. + """ name: String! + """ + The values of the attribute. + If the attribute is not a list, the vector must contain exactly one element. + Integers (signed 64 bits) are represented as strings. + Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z". + JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs. + """ value: [String!]! +} + +"The details required to create a group." +input CreateGroupInput { + displayName: String! + "User-defined attributes." attributes: [AttributeValueInput!] } type User { diff --git a/server/src/domain/types.rs b/server/src/domain/types.rs index 02fad38..b5d39b5 100644 --- a/server/src/domain/types.rs +++ b/server/src/domain/types.rs @@ -205,13 +205,11 @@ impl TryFrom> for JpegPhoto { } } -impl TryFrom for JpegPhoto { +impl TryFrom<&str> for JpegPhoto { type Error = anyhow::Error; - fn try_from(string: String) -> anyhow::Result { + fn try_from(string: &str) -> anyhow::Result { // The String format is in base64. - >::try_from( - base64::engine::general_purpose::STANDARD.decode(string.as_str())?, - ) + >::try_from(base64::engine::general_purpose::STANDARD.decode(string)?) } } diff --git a/server/src/infra/access_control.rs b/server/src/infra/access_control.rs index 8d449b7..cb7bc8f 100644 --- a/server/src/infra/access_control.rs +++ b/server/src/infra/access_control.rs @@ -11,6 +11,7 @@ use crate::domain::{ ReadSchemaBackendHandler, Schema, SchemaBackendHandler, UpdateGroupRequest, UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter, }, + schema::PublicSchema, types::{Group, GroupDetails, GroupId, User, UserAndGroups, UserId}, }; @@ -71,9 +72,10 @@ impl ValidationResults { } #[async_trait] -pub trait UserReadableBackendHandler { +pub trait UserReadableBackendHandler: ReadSchemaBackendHandler { async fn get_user_details(&self, user_id: &UserId) -> Result; async fn get_user_groups(&self, user_id: &UserId) -> Result>; + async fn get_schema(&self) -> Result; } #[async_trait] @@ -120,6 +122,11 @@ impl UserReadableBackendHandler for Handler { async fn get_user_groups(&self, user_id: &UserId) -> Result> { ::get_user_groups(self, user_id).await } + async fn get_schema(&self) -> Result { + Ok(PublicSchema::from( + ::get_schema(self).await?, + )) + } } #[async_trait] diff --git a/server/src/infra/graphql/mutation.rs b/server/src/infra/graphql/mutation.rs index eed5653..c584c76 100644 --- a/server/src/infra/graphql/mutation.rs +++ b/server/src/infra/graphql/mutation.rs @@ -1,10 +1,13 @@ use crate::{ domain::{ handler::{ - BackendHandler, CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, - UpdateGroupRequest, UpdateUserRequest, + AttributeList, BackendHandler, CreateAttributeRequest, CreateGroupRequest, + CreateUserRequest, UpdateGroupRequest, UpdateUserRequest, + }, + types::{ + AttributeType, AttributeValue as DomainAttributeValue, GroupId, JpegPhoto, Serialized, + UserId, }, - types::{AttributeType, GroupId, JpegPhoto, UserId}, }, infra::{ access_control::{ @@ -14,10 +17,10 @@ use crate::{ graphql::api::{field_error_callback, Context}, }, }; -use anyhow::Context as AnyhowContext; +use anyhow::{anyhow, Context as AnyhowContext}; use base64::Engine; use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject}; -use tracing::{debug, debug_span, Instrument}; +use tracing::{debug, debug_span, Instrument, Span}; #[derive(PartialEq, Eq, Debug)] /// The top-level GraphQL mutation type. @@ -33,6 +36,21 @@ impl Mutation { } } +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +// This conflicts with the attribute values returned by the user/group queries. +#[graphql(name = "AttributeValueInput")] +struct AttributeValue { + /// The name of the attribute. It must be present in the schema, and the type informs how + /// to interpret the values. + name: String, + /// The values of the attribute. + /// If the attribute is not a list, the vector must contain exactly one element. + /// Integers (signed 64 bits) are represented as strings. + /// Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z". + /// JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs. + value: Vec, +} + #[derive(PartialEq, Eq, Debug, GraphQLInputObject)] /// The details required to create a user. pub struct CreateUserInput { @@ -41,8 +59,18 @@ pub struct CreateUserInput { display_name: Option, first_name: Option, last_name: Option, - // Base64 encoded JpegPhoto. + /// Base64 encoded JpegPhoto. avatar: Option, + /// User-defined attributes. + attributes: Option>, +} + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +/// The details required to create a group. +pub struct CreateGroupInput { + display_name: String, + /// User-defined attributes. + attributes: Option>, } #[derive(PartialEq, Eq, Debug, GraphQLInputObject)] @@ -53,15 +81,29 @@ pub struct UpdateUserInput { display_name: Option, first_name: Option, last_name: Option, - // Base64 encoded JpegPhoto. + /// Base64 encoded JpegPhoto. avatar: Option, + /// Attribute names to remove. + /// They are processed before insertions. + remove_attributes: Option>, + /// Inserts or updates the given attributes. + /// For lists, the entire list must be provided. + insert_attributes: Option>, } #[derive(PartialEq, Eq, Debug, GraphQLInputObject)] /// The fields that can be updated for a group. pub struct UpdateGroupInput { + /// The group ID. id: i32, + /// The new display name. display_name: Option, + /// Attribute names to remove. + /// They are processed before insertions. + remove_attributes: Option>, + /// Inserts or updates the given attributes. + /// For lists, the entire list must be provided. + insert_attributes: Option>, } #[derive(PartialEq, Eq, Debug, GraphQLObject)] @@ -97,6 +139,13 @@ impl Mutation { .map(JpegPhoto::try_from) .transpose() .context("Provided image is not a valid JPEG")?; + let schema = handler.get_schema().await?; + let attributes = user + .attributes + .unwrap_or_default() + .into_iter() + .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr)) + .collect::, _>>()?; handler .create_user(CreateUserRequest { user_id: user_id.clone(), @@ -105,7 +154,7 @@ impl Mutation { first_name: user.first_name, last_name: user.last_name, avatar, - ..Default::default() + attributes, }) .instrument(span.clone()) .await?; @@ -124,19 +173,25 @@ impl Mutation { span.in_scope(|| { debug!(?name); }); - let handler = context - .get_admin_handler() - .ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?; - let request = CreateGroupRequest { - display_name: name, - ..Default::default() - }; - let group_id = handler.create_group(request).await?; - Ok(handler - .get_group_details(group_id) - .instrument(span) - .await - .map(Into::into)?) + create_group_with_details( + context, + CreateGroupInput { + display_name: name, + attributes: Some(Vec::new()), + }, + span, + ) + .await + } + async fn create_group_with_details( + context: &Context, + request: CreateGroupInput, + ) -> FieldResult> { + let span = debug_span!("[GraphQL mutation] create_group_with_details"); + span.in_scope(|| { + debug!(?request); + }); + create_group_with_details(context, request, span).await } async fn update_user( @@ -159,6 +214,13 @@ impl Mutation { .map(JpegPhoto::try_from) .transpose() .context("Provided image is not a valid JPEG")?; + let schema = handler.get_schema().await?; + let insert_attributes = user + .insert_attributes + .unwrap_or_default() + .into_iter() + .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr)) + .collect::, _>>()?; handler .update_user(UpdateUserRequest { user_id, @@ -167,7 +229,8 @@ impl Mutation { first_name: user.first_name, last_name: user.last_name, avatar, - ..Default::default() + delete_attributes: user.remove_attributes.unwrap_or_default(), + insert_attributes, }) .instrument(span) .await?; @@ -185,16 +248,23 @@ impl Mutation { let handler = context .get_admin_handler() .ok_or_else(field_error_callback(&span, "Unauthorized group update"))?; - if group.id == 1 { - span.in_scope(|| debug!("Cannot change admin group details")); - return Err("Cannot change admin group details".into()); + if group.id == 1 && group.display_name.is_some() { + span.in_scope(|| debug!("Cannot change lldap_admin group name")); + return Err("Cannot change lldap_admin group name".into()); } + let schema = handler.get_schema().await?; + let insert_attributes = group + .insert_attributes + .unwrap_or_default() + .into_iter() + .map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr)) + .collect::, _>>()?; handler .update_group(UpdateGroupRequest { group_id: GroupId(group.id), display_name: group.display_name, - delete_attributes: Vec::new(), - insert_attributes: Vec::new(), + delete_attributes: group.remove_attributes.unwrap_or_default(), + insert_attributes, }) .instrument(span) .await?; @@ -390,3 +460,91 @@ impl Mutation { Ok(Success::new()) } } + +async fn create_group_with_details( + context: &Context, + request: CreateGroupInput, + span: Span, +) -> FieldResult> { + let handler = context + .get_admin_handler() + .ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?; + let schema = handler.get_schema().await?; + let attributes = request + .attributes + .unwrap_or_default() + .into_iter() + .map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr)) + .collect::, _>>()?; + let request = CreateGroupRequest { + display_name: request.display_name, + attributes, + }; + let group_id = handler.create_group(request).await?; + Ok(handler + .get_group_details(group_id) + .instrument(span) + .await + .map(Into::into)?) +} + +fn deserialize_attribute( + attribute_schema: &AttributeList, + attribute: AttributeValue, +) -> FieldResult { + let attribute_type = attribute_schema + .get_attribute_type(&attribute.name) + .ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?; + if !attribute_type.1 && attribute.value.len() != 1 { + return Err(anyhow!( + "Attribute {} is not a list, but multiple values were provided", + attribute.name + ) + .into()); + } + let parse_int = |value: &String| -> FieldResult { + Ok(value + .parse::() + .with_context(|| format!("Invalid integer value {}", value))?) + }; + let parse_date = |value: &String| -> FieldResult { + Ok(chrono::DateTime::parse_from_rfc3339(value) + .with_context(|| format!("Invalid date value {}", value))? + .naive_utc()) + }; + let parse_photo = |value: &String| -> FieldResult { + Ok(JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")?) + }; + let deserialized_values = match attribute_type { + (AttributeType::String, false) => Serialized::from(&attribute.value[0]), + (AttributeType::String, true) => Serialized::from(&attribute.value), + (AttributeType::Integer, false) => Serialized::from(&parse_int(&attribute.value[0])?), + (AttributeType::Integer, true) => Serialized::from( + &attribute + .value + .iter() + .map(parse_int) + .collect::>>()?, + ), + (AttributeType::DateTime, false) => Serialized::from(&parse_date(&attribute.value[0])?), + (AttributeType::DateTime, true) => Serialized::from( + &attribute + .value + .iter() + .map(parse_date) + .collect::>>()?, + ), + (AttributeType::JpegPhoto, false) => Serialized::from(&parse_photo(&attribute.value[0])?), + (AttributeType::JpegPhoto, true) => Serialized::from( + &attribute + .value + .iter() + .map(parse_photo) + .collect::>>()?, + ), + }; + Ok(DomainAttributeValue { + name: attribute.name, + value: deserialized_values, + }) +} diff --git a/server/tests/common/fixture.rs b/server/tests/common/fixture.rs index 2be47d4..b970c2c 100644 --- a/server/tests/common/fixture.rs +++ b/server/tests/common/fixture.rs @@ -108,6 +108,7 @@ impl LLDAPFixture { display_name: None, first_name: None, last_name: None, + attributes: None, }, }, )