diff --git a/crates/graphql-server/src/mutation/helpers.rs b/crates/graphql-server/src/mutation/helpers.rs new file mode 100644 index 0000000..b95a58b --- /dev/null +++ b/crates/graphql-server/src/mutation/helpers.rs @@ -0,0 +1,160 @@ +use anyhow::{Context as AnyhowContext, anyhow}; +use juniper::FieldResult; +use lldap_access_control::{AdminBackendHandler, ReadonlyBackendHandler}; +use lldap_domain::{ + deserialize::deserialize_attribute_value, + public_schema::PublicSchema, + requests::CreateGroupRequest, + schema::AttributeList, + types::{Attribute as DomainAttribute, AttributeName, Email}, +}; +use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler}; +use std::{collections::BTreeMap, sync::Arc}; +use tracing::{Instrument, Span}; + +use super::inputs::AttributeValue; +use crate::api::{Context, field_error_callback}; + +pub struct UnpackedAttributes { + pub email: Option, + pub display_name: Option, + pub attributes: Vec, +} + +pub fn unpack_attributes( + attributes: Vec, + schema: &PublicSchema, + is_admin: bool, +) -> FieldResult { + let email = attributes + .iter() + .find(|attr| attr.name == "mail") + .cloned() + .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin)) + .transpose()? + .map(|attr| attr.value.into_string().unwrap()) + .map(Email::from); + let display_name = attributes + .iter() + .find(|attr| attr.name == "display_name") + .cloned() + .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin)) + .transpose()? + .map(|attr| attr.value.into_string().unwrap()); + let attributes = attributes + .into_iter() + .filter(|attr| attr.name != "mail" && attr.name != "display_name") + .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin)) + .collect::, _>>()?; + Ok(UnpackedAttributes { + email, + display_name, + attributes, + }) +} + +/// Consolidates caller supplied user fields and attributes into a list of attributes. +/// +/// A number of user fields are internally represented as attributes, but are still also +/// available as fields on user objects. This function consolidates these fields and the +/// given attributes into a resulting attribute list. If a value is supplied for both a +/// field and the corresponding attribute, the attribute will take precedence. +pub fn consolidate_attributes( + attributes: Vec, + first_name: Option, + last_name: Option, + avatar: Option, +) -> Vec { + // Prepare map of the client provided attributes + let mut provided_attributes: BTreeMap = attributes + .into_iter() + .map(|x| { + ( + x.name.clone().into(), + AttributeValue { + name: x.name.to_ascii_lowercase(), + value: x.value, + }, + ) + }) + .collect::>(); + // Prepare list of fallback attribute values + let field_attrs = [ + ("first_name", first_name), + ("last_name", last_name), + ("avatar", avatar), + ]; + for (name, value) in field_attrs.into_iter() { + if let Some(val) = value { + let attr_name: AttributeName = name.into(); + provided_attributes + .entry(attr_name) + .or_insert_with(|| AttributeValue { + name: name.to_string(), + value: vec![val], + }); + } + } + // Return the values of the resulting map + provided_attributes.into_values().collect() +} + +pub async fn create_group_with_details( + context: &Context, + request: super::inputs::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 public_schema: PublicSchema = schema.into(); + let attributes = request + .attributes + .unwrap_or_default() + .into_iter() + .map(|attr| deserialize_attribute(&public_schema.get_schema().group_attributes, attr, true)) + .collect::, _>>()?; + let request = CreateGroupRequest { + display_name: request.display_name.into(), + attributes, + }; + let group_id = handler.create_group(request).await?; + let group_details = handler.get_group_details(group_id).instrument(span).await?; + crate::query::Group::::from_group_details(group_details, Arc::new(public_schema)) +} + +pub fn deserialize_attribute( + attribute_schema: &AttributeList, + attribute: AttributeValue, + is_admin: bool, +) -> FieldResult { + let attribute_name = AttributeName::from(attribute.name.as_str()); + let attribute_schema = attribute_schema + .get_attribute_schema(&attribute_name) + .ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?; + if attribute_schema.is_readonly { + return Err(anyhow!( + "Permission denied: Attribute {} is read-only", + attribute.name + ) + .into()); + } + if !is_admin && !attribute_schema.is_editable { + return Err(anyhow!( + "Permission denied: Attribute {} is not editable by regular users", + attribute.name + ) + .into()); + } + let deserialized_values = deserialize_attribute_value( + &attribute.value, + attribute_schema.attribute_type, + attribute_schema.is_list, + ) + .context(format!("While deserializing attribute {}", attribute.name))?; + Ok(DomainAttribute { + name: attribute_name, + value: deserialized_values, + }) +} diff --git a/crates/graphql-server/src/mutation/inputs.rs b/crates/graphql-server/src/mutation/inputs.rs new file mode 100644 index 0000000..f23adf7 --- /dev/null +++ b/crates/graphql-server/src/mutation/inputs.rs @@ -0,0 +1,99 @@ +use juniper::{GraphQLInputObject, GraphQLObject}; + +#[derive(Clone, PartialEq, Eq, Debug, GraphQLInputObject)] +// This conflicts with the attribute values returned by the user/group queries. +#[graphql(name = "AttributeValueInput")] +pub struct AttributeValue { + /// The name of the attribute. It must be present in the schema, and the type informs how + /// to interpret the values. + pub 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. + pub value: Vec, +} + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +/// The details required to create a user. +pub struct CreateUserInput { + pub id: String, + // The email can be specified as an attribute, but one of the two is required. + pub email: Option, + pub display_name: Option, + /// First name of user. Deprecated: use attribute instead. + /// If both field and corresponding attribute is supplied, the attribute will take precedence. + pub first_name: Option, + /// Last name of user. Deprecated: use attribute instead. + /// If both field and corresponding attribute is supplied, the attribute will take precedence. + pub last_name: Option, + /// Base64 encoded JpegPhoto. Deprecated: use attribute instead. + /// If both field and corresponding attribute is supplied, the attribute will take precedence. + pub avatar: Option, + /// Attributes. + pub attributes: Option>, +} + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +/// The details required to create a group. +pub struct CreateGroupInput { + pub display_name: String, + /// User-defined attributes. + pub attributes: Option>, +} + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +/// The fields that can be updated for a user. +pub struct UpdateUserInput { + pub id: String, + pub email: Option, + pub display_name: Option, + /// First name of user. Deprecated: use attribute instead. + /// If both field and corresponding attribute is supplied, the attribute will take precedence. + pub first_name: Option, + /// Last name of user. Deprecated: use attribute instead. + /// If both field and corresponding attribute is supplied, the attribute will take precedence. + pub last_name: Option, + /// Base64 encoded JpegPhoto. Deprecated: use attribute instead. + /// If both field and corresponding attribute is supplied, the attribute will take precedence. + pub avatar: Option, + /// Attribute names to remove. + /// They are processed before insertions. + pub remove_attributes: Option>, + /// Inserts or updates the given attributes. + /// For lists, the entire list must be provided. + pub insert_attributes: Option>, +} + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +/// The fields that can be updated for a group. +pub struct UpdateGroupInput { + /// The group ID. + pub id: i32, + /// The new display name. + pub display_name: Option, + /// Attribute names to remove. + /// They are processed before insertions. + pub remove_attributes: Option>, + /// Inserts or updates the given attributes. + /// For lists, the entire list must be provided. + pub insert_attributes: Option>, +} + +#[derive(PartialEq, Eq, Debug, GraphQLObject)] +pub struct Success { + ok: bool, +} + +impl Success { + pub fn new() -> Self { + Self { ok: true } + } +} + +impl Default for Success { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/graphql-server/src/mutation.rs b/crates/graphql-server/src/mutation/mod.rs similarity index 76% rename from crates/graphql-server/src/mutation.rs rename to crates/graphql-server/src/mutation/mod.rs index c333869..471b610 100644 --- a/crates/graphql-server/src/mutation.rs +++ b/crates/graphql-server/src/mutation/mod.rs @@ -1,27 +1,30 @@ +pub mod helpers; +pub mod inputs; + +// Re-export public types +pub use inputs::{ + AttributeValue, CreateGroupInput, CreateUserInput, Success, UpdateGroupInput, UpdateUserInput, +}; + use crate::api::{Context, field_error_callback}; -use anyhow::{Context as AnyhowContext, anyhow}; -use juniper::{FieldError, FieldResult, GraphQLInputObject, GraphQLObject, graphql_object}; +use anyhow::anyhow; +use juniper::{FieldError, FieldResult, graphql_object}; use lldap_access_control::{ - AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler, - UserWriteableBackendHandler, + AdminBackendHandler, UserReadableBackendHandler, UserWriteableBackendHandler, }; use lldap_domain::{ - deserialize::deserialize_attribute_value, - public_schema::PublicSchema, - requests::{ - CreateAttributeRequest, CreateGroupRequest, CreateUserRequest, UpdateGroupRequest, - UpdateUserRequest, - }, - schema::AttributeList, - types::{ - Attribute as DomainAttribute, AttributeName, AttributeType, Email, GroupId, - LdapObjectClass, UserId, - }, + requests::{CreateAttributeRequest, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest}, + types::{AttributeName, AttributeType, Email, GroupId, LdapObjectClass, UserId}, }; use lldap_domain_handlers::handler::BackendHandler; use lldap_validation::attributes::{ALLOWED_CHARACTERS_DESCRIPTION, validate_attribute_name}; -use std::{collections::BTreeMap, sync::Arc}; -use tracing::{Instrument, Span, debug, debug_span}; +use std::sync::Arc; +use tracing::{Instrument, debug, debug_span}; + +use helpers::{ + UnpackedAttributes, consolidate_attributes, create_group_with_details, deserialize_attribute, + unpack_attributes, +}; #[derive(PartialEq, Eq, Debug)] /// The top-level GraphQL mutation type. @@ -42,183 +45,6 @@ impl Mutation { } } } - -#[derive(Clone, 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 { - id: String, - // The email can be specified as an attribute, but one of the two is required. - email: Option, - display_name: Option, - /// First name of user. Deprecated: use attribute instead. - /// If both field and corresponding attribute is supplied, the attribute will take precedence. - first_name: Option, - /// Last name of user. Deprecated: use attribute instead. - /// If both field and corresponding attribute is supplied, the attribute will take precedence. - last_name: Option, - /// Base64 encoded JpegPhoto. Deprecated: use attribute instead. - /// If both field and corresponding attribute is supplied, the attribute will take precedence. - avatar: Option, - /// 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)] -/// The fields that can be updated for a user. -pub struct UpdateUserInput { - id: String, - email: Option, - display_name: Option, - /// First name of user. Deprecated: use attribute instead. - /// If both field and corresponding attribute is supplied, the attribute will take precedence. - first_name: Option, - /// Last name of user. Deprecated: use attribute instead. - /// If both field and corresponding attribute is supplied, the attribute will take precedence. - last_name: Option, - /// Base64 encoded JpegPhoto. Deprecated: use attribute instead. - /// If both field and corresponding attribute is supplied, the attribute will take precedence. - 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)] -pub struct Success { - ok: bool, -} - -impl Success { - fn new() -> Self { - Self { ok: true } - } -} - -struct UnpackedAttributes { - email: Option, - display_name: Option, - attributes: Vec, -} - -fn unpack_attributes( - attributes: Vec, - schema: &PublicSchema, - is_admin: bool, -) -> FieldResult { - let email = attributes - .iter() - .find(|attr| attr.name == "mail") - .cloned() - .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin)) - .transpose()? - .map(|attr| attr.value.into_string().unwrap()) - .map(Email::from); - let display_name = attributes - .iter() - .find(|attr| attr.name == "display_name") - .cloned() - .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin)) - .transpose()? - .map(|attr| attr.value.into_string().unwrap()); - let attributes = attributes - .into_iter() - .filter(|attr| attr.name != "mail" && attr.name != "display_name") - .map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr, is_admin)) - .collect::, _>>()?; - Ok(UnpackedAttributes { - email, - display_name, - attributes, - }) -} - -/// Consolidates caller supplied user fields and attributes into a list of attributes. -/// -/// A number of user fields are internally represented as attributes, but are still also -/// available as fields on user objects. This function consolidates these fields and the -/// given attributes into a resulting attribute list. If a value is supplied for both a -/// field and the corresponding attribute, the attribute will take precedence. -fn consolidate_attributes( - attributes: Vec, - first_name: Option, - last_name: Option, - avatar: Option, -) -> Vec { - // Prepare map of the client provided attributes - let mut provided_attributes: BTreeMap = attributes - .into_iter() - .map(|x| { - ( - x.name.clone().into(), - AttributeValue { - name: x.name.to_ascii_lowercase(), - value: x.value, - }, - ) - }) - .collect::>(); - // Prepare list of fallback attribute values - let field_attrs = [ - ("first_name", first_name), - ("last_name", last_name), - ("avatar", avatar), - ]; - for (name, value) in field_attrs.into_iter() { - if let Some(val) = value { - let attr_name: AttributeName = name.into(); - provided_attributes - .entry(attr_name) - .or_insert_with(|| AttributeValue { - name: name.to_string(), - value: vec![val], - }); - } - } - // Return the values of the resulting map - provided_attributes.into_values().collect() -} - #[graphql_object(context = Context)] impl Mutation { async fn create_user( @@ -721,66 +547,6 @@ 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, true)) - .collect::, _>>()?; - let request = CreateGroupRequest { - display_name: request.display_name.into(), - attributes, - }; - let group_id = handler.create_group(request).await?; - let group_details = handler.get_group_details(group_id).instrument(span).await?; - super::query::Group::::from_group_details(group_details, Arc::new(schema)) -} - -fn deserialize_attribute( - attribute_schema: &AttributeList, - attribute: AttributeValue, - is_admin: bool, -) -> FieldResult { - let attribute_name = AttributeName::from(attribute.name.as_str()); - let attribute_schema = attribute_schema - .get_attribute_schema(&attribute_name) - .ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?; - if attribute_schema.is_readonly { - return Err(anyhow!( - "Permission denied: Attribute {} is read-only", - attribute.name - ) - .into()); - } - if !is_admin && !attribute_schema.is_editable { - return Err(anyhow!( - "Permission denied: Attribute {} is not editable by regular users", - attribute.name - ) - .into()); - } - let deserialized_values = deserialize_attribute_value( - &attribute.value, - attribute_schema.attribute_type, - attribute_schema.is_list, - ) - .context(format!("While deserializing attribute {}", attribute.name))?; - Ok(DomainAttribute { - name: attribute_name, - value: deserialized_values, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/graphql-server/src/query.rs b/crates/graphql-server/src/query.rs deleted file mode 100644 index 0a6885c..0000000 --- a/crates/graphql-server/src/query.rs +++ /dev/null @@ -1,1427 +0,0 @@ -use crate::api::{Context, field_error_callback}; -use anyhow::Context as AnyhowContext; -use chrono::TimeZone; -use juniper::{FieldResult, GraphQLInputObject, graphql_object}; -use lldap_access_control::{ReadonlyBackendHandler, UserReadableBackendHandler}; -use lldap_domain::{ - deserialize::deserialize_attribute_value, - public_schema::PublicSchema, - types::{AttributeType, Cardinality, GroupDetails, GroupId, LdapObjectClass, UserId}, -}; -use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler}; -use lldap_domain_model::model::UserColumn; -use lldap_ldap::{ - UserFieldType, get_default_group_object_classes, get_default_user_object_classes, - map_user_field, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tracing::{Instrument, Span, debug, debug_span}; - -type DomainRequestFilter = lldap_domain_handlers::handler::UserRequestFilter; -type DomainUser = lldap_domain::types::User; -type DomainGroup = lldap_domain::types::Group; -type DomainUserAndGroups = lldap_domain::types::UserAndGroups; -type DomainAttributeList = lldap_domain::schema::AttributeList; -type DomainAttributeSchema = lldap_domain::schema::AttributeSchema; -type DomainAttribute = lldap_domain::types::Attribute; -type DomainAttributeValue = lldap_domain::types::AttributeValue; - -#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] -/// A filter for requests, specifying a boolean expression based on field constraints. Only one of -/// the fields can be set at a time. -pub struct RequestFilter { - any: Option>, - all: Option>, - not: Option>, - eq: Option, - member_of: Option, - member_of_id: Option, -} - -impl RequestFilter { - fn try_into_domain_filter(self, schema: &PublicSchema) -> FieldResult { - match ( - self.eq, - self.any, - self.all, - self.not, - self.member_of, - self.member_of_id, - ) { - (Some(eq), None, None, None, None, None) => { - match map_user_field(&eq.field.as_str().into(), schema) { - UserFieldType::NoMatch => { - Err(format!("Unknown request filter: {}", &eq.field).into()) - } - UserFieldType::PrimaryField(UserColumn::UserId) => { - Ok(DomainRequestFilter::UserId(UserId::new(&eq.value))) - } - UserFieldType::PrimaryField(column) => { - Ok(DomainRequestFilter::Equality(column, eq.value)) - } - UserFieldType::Attribute(name, typ, false) => { - let value = deserialize_attribute_value(&[eq.value], typ, false) - .context(format!("While deserializing attribute {}", &name))?; - Ok(DomainRequestFilter::AttributeEquality(name, value)) - } - UserFieldType::Attribute(_, _, true) => { - Err("Equality not supported for list fields".into()) - } - UserFieldType::MemberOf => Ok(DomainRequestFilter::MemberOf(eq.value.into())), - UserFieldType::ObjectClass | UserFieldType::Dn | UserFieldType::EntryDn => { - Err("Ldap fields not supported in request filter".into()) - } - } - } - (None, Some(any), None, None, None, None) => Ok(DomainRequestFilter::Or( - any.into_iter() - .map(|f| f.try_into_domain_filter(schema)) - .collect::>>()?, - )), - (None, None, Some(all), None, None, None) => Ok(DomainRequestFilter::And( - all.into_iter() - .map(|f| f.try_into_domain_filter(schema)) - .collect::>>()?, - )), - (None, None, None, Some(not), None, None) => Ok(DomainRequestFilter::Not(Box::new( - (*not).try_into_domain_filter(schema)?, - ))), - (None, None, None, None, Some(group), None) => { - Ok(DomainRequestFilter::MemberOf(group.into())) - } - (None, None, None, None, None, Some(group_id)) => { - Ok(DomainRequestFilter::MemberOfId(GroupId(group_id))) - } - (None, None, None, None, None, None) => { - Err("No field specified in request filter".into()) - } - _ => Err("Multiple fields specified in request filter".into()), - } - } -} - -#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] -pub struct EqualityConstraint { - field: String, - value: String, -} - -#[derive(PartialEq, Eq, Debug)] -/// The top-level GraphQL query type. -pub struct Query { - _phantom: std::marker::PhantomData>, -} - -impl Default for Query { - fn default() -> Self { - Self::new() - } -} - -impl Query { - pub fn new() -> Self { - Self { - _phantom: std::marker::PhantomData, - } - } -} - -#[graphql_object(context = Context)] -impl Query { - fn api_version() -> &'static str { - "1.0" - } - - pub async fn user(context: &Context, user_id: String) -> FieldResult> { - use anyhow::Context; - let span = debug_span!("[GraphQL query] user"); - span.in_scope(|| { - debug!(?user_id); - }); - let user_id = urlencoding::decode(&user_id).context("Invalid user parameter")?; - let user_id = UserId::new(&user_id); - let handler = context - .get_readable_handler(&user_id) - .ok_or_else(field_error_callback( - &span, - "Unauthorized access to user data", - ))?; - let schema = Arc::new(self.get_schema(context, span.clone()).await?); - let user = handler.get_user_details(&user_id).instrument(span).await?; - User::::from_user(user, schema) - } - - async fn users( - context: &Context, - #[graphql(name = "where")] filters: Option, - ) -> FieldResult>> { - let span = debug_span!("[GraphQL query] users"); - span.in_scope(|| { - debug!(?filters); - }); - let handler = context - .get_readonly_handler() - .ok_or_else(field_error_callback( - &span, - "Unauthorized access to user list", - ))?; - let schema = Arc::new(self.get_schema(context, span.clone()).await?); - let users = handler - .list_users( - filters - .map(|f| f.try_into_domain_filter(&schema)) - .transpose()?, - false, - ) - .instrument(span) - .await?; - users - .into_iter() - .map(|u| User::::from_user_and_groups(u, schema.clone())) - .collect() - } - - async fn groups(context: &Context) -> FieldResult>> { - let span = debug_span!("[GraphQL query] groups"); - let handler = context - .get_readonly_handler() - .ok_or_else(field_error_callback( - &span, - "Unauthorized access to group list", - ))?; - let schema = Arc::new(self.get_schema(context, span.clone()).await?); - let domain_groups = handler.list_groups(None).instrument(span).await?; - domain_groups - .into_iter() - .map(|g| Group::::from_group(g, schema.clone())) - .collect() - } - - async fn group(context: &Context, group_id: i32) -> FieldResult> { - let span = debug_span!("[GraphQL query] group"); - span.in_scope(|| { - debug!(?group_id); - }); - let handler = context - .get_readonly_handler() - .ok_or_else(field_error_callback( - &span, - "Unauthorized access to group data", - ))?; - let schema = Arc::new(self.get_schema(context, span.clone()).await?); - let group_details = handler - .get_group_details(GroupId(group_id)) - .instrument(span) - .await?; - Group::::from_group_details(group_details, schema.clone()) - } - - async fn schema(context: &Context) -> FieldResult> { - let span = debug_span!("[GraphQL query] get_schema"); - self.get_schema(context, span).await.map(Into::into) - } -} - -impl Query { - async fn get_schema( - &self, - context: &Context, - span: Span, - ) -> FieldResult { - let handler = context - .handler - .get_user_restricted_lister_handler(&context.validation_result); - Ok(handler - .get_schema() - .instrument(span) - .await - .map(Into::::into)?) - } -} - -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -/// Represents a single user. -pub struct User { - user: DomainUser, - attributes: Vec>, - schema: Arc, - groups: Option>>, - _phantom: std::marker::PhantomData>, -} - -impl User { - pub fn from_user(mut user: DomainUser, schema: Arc) -> FieldResult { - let attributes = AttributeValue::::user_attributes_from_schema(&mut user, &schema); - Ok(Self { - user, - attributes, - schema, - groups: None, - _phantom: std::marker::PhantomData, - }) - } -} - -impl User { - pub fn from_user_and_groups( - DomainUserAndGroups { user, groups }: DomainUserAndGroups, - schema: Arc, - ) -> FieldResult { - let mut user = Self::from_user(user, schema.clone())?; - if let Some(groups) = groups { - user.groups = Some( - groups - .into_iter() - .map(|g| Group::::from_group_details(g, schema.clone())) - .collect::>>()?, - ); - } - Ok(user) - } -} - -#[graphql_object(context = Context)] -impl User { - fn id(&self) -> &str { - self.user.user_id.as_str() - } - - fn email(&self) -> &str { - self.user.email.as_str() - } - - fn display_name(&self) -> &str { - self.user.display_name.as_deref().unwrap_or("") - } - - fn first_name(&self) -> &str { - self.attributes - .iter() - .find(|a| a.attribute.name.as_str() == "first_name") - .map(|a| a.attribute.value.as_str().unwrap_or_default()) - .unwrap_or_default() - } - - fn last_name(&self) -> &str { - self.attributes - .iter() - .find(|a| a.attribute.name.as_str() == "last_name") - .map(|a| a.attribute.value.as_str().unwrap_or_default()) - .unwrap_or_default() - } - - fn avatar(&self) -> Option { - self.attributes - .iter() - .find(|a| a.attribute.name.as_str() == "avatar") - .map(|a| { - String::from( - a.attribute - .value - .as_jpeg_photo() - .expect("Invalid JPEG returned by the DB"), - ) - }) - } - - fn creation_date(&self) -> chrono::DateTime { - chrono::Utc.from_utc_datetime(&self.user.creation_date) - } - - fn uuid(&self) -> &str { - self.user.uuid.as_str() - } - - /// User-defined attributes. - fn attributes(&self) -> &[AttributeValue] { - &self.attributes - } - - /// The groups to which this user belongs. - async fn groups(&self, context: &Context) -> FieldResult>> { - if let Some(groups) = &self.groups { - return Ok(groups.clone()); - } - let span = debug_span!("[GraphQL query] user::groups"); - span.in_scope(|| { - debug!(user_id = ?self.user.user_id); - }); - let handler = context - .get_readable_handler(&self.user.user_id) - .expect("We shouldn't be able to get there without readable permission"); - let domain_groups = handler - .get_user_groups(&self.user.user_id) - .instrument(span) - .await?; - let mut groups = domain_groups - .into_iter() - .map(|g| Group::::from_group_details(g, self.schema.clone())) - .collect::>>>()?; - groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name)); - Ok(groups) - } -} - -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -/// Represents a single group. -pub struct Group { - group_id: i32, - display_name: String, - creation_date: chrono::NaiveDateTime, - uuid: String, - attributes: Vec>, - schema: Arc, - _phantom: std::marker::PhantomData>, -} - -impl Group { - pub fn from_group( - mut group: DomainGroup, - schema: Arc, - ) -> FieldResult> { - let attributes = - AttributeValue::::group_attributes_from_schema(&mut group, &schema); - Ok(Self { - group_id: group.id.0, - display_name: group.display_name.to_string(), - creation_date: group.creation_date, - uuid: group.uuid.into_string(), - attributes, - schema, - _phantom: std::marker::PhantomData, - }) - } - - pub fn from_group_details( - mut group_details: GroupDetails, - schema: Arc, - ) -> FieldResult> { - let attributes = AttributeValue::::group_details_attributes_from_schema( - &mut group_details, - &schema, - ); - Ok(Self { - group_id: group_details.group_id.0, - display_name: group_details.display_name.to_string(), - creation_date: group_details.creation_date, - uuid: group_details.uuid.into_string(), - attributes, - schema, - _phantom: std::marker::PhantomData, - }) - } -} - -impl Clone for Group { - fn clone(&self) -> Self { - Self { - group_id: self.group_id, - display_name: self.display_name.clone(), - creation_date: self.creation_date, - uuid: self.uuid.clone(), - attributes: self.attributes.clone(), - schema: self.schema.clone(), - _phantom: std::marker::PhantomData, - } - } -} - -#[graphql_object(context = Context)] -impl Group { - fn id(&self) -> i32 { - self.group_id - } - fn display_name(&self) -> String { - self.display_name.clone() - } - fn creation_date(&self) -> chrono::DateTime { - chrono::Utc.from_utc_datetime(&self.creation_date) - } - fn uuid(&self) -> String { - self.uuid.clone() - } - - /// User-defined attributes. - fn attributes(&self) -> &[AttributeValue] { - &self.attributes - } - - /// The groups to which this user belongs. - async fn users(&self, context: &Context) -> FieldResult>> { - let span = debug_span!("[GraphQL query] group::users"); - span.in_scope(|| { - debug!(name = %self.display_name); - }); - let handler = context - .get_readonly_handler() - .ok_or_else(field_error_callback( - &span, - "Unauthorized access to group data", - ))?; - let domain_users = handler - .list_users( - Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))), - false, - ) - .instrument(span) - .await?; - domain_users - .into_iter() - .map(|u| User::::from_user_and_groups(u, self.schema.clone())) - .collect() - } -} - -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -pub struct AttributeSchema { - schema: DomainAttributeSchema, - _phantom: std::marker::PhantomData>, -} - -#[graphql_object(context = Context)] -impl AttributeSchema { - fn name(&self) -> String { - self.schema.name.to_string() - } - fn attribute_type(&self) -> AttributeType { - self.schema.attribute_type - } - fn is_list(&self) -> bool { - self.schema.is_list - } - fn is_visible(&self) -> bool { - self.schema.is_visible - } - fn is_editable(&self) -> bool { - self.schema.is_editable - } - fn is_hardcoded(&self) -> bool { - self.schema.is_hardcoded - } - fn is_readonly(&self) -> bool { - self.schema.is_readonly - } -} - -impl Clone for AttributeSchema { - fn clone(&self) -> Self { - Self { - schema: self.schema.clone(), - _phantom: std::marker::PhantomData, - } - } -} - -impl From for AttributeSchema { - fn from(value: DomainAttributeSchema) -> Self { - Self { - schema: value, - _phantom: std::marker::PhantomData, - } - } -} - -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -pub struct AttributeList { - attributes: DomainAttributeList, - default_classes: Vec, - extra_classes: Vec, - _phantom: std::marker::PhantomData>, -} - -#[derive(Clone)] -pub struct ObjectClassInfo { - object_class: String, - is_hardcoded: bool, -} - -#[graphql_object] -impl ObjectClassInfo { - fn object_class(&self) -> &str { - &self.object_class - } - - fn is_hardcoded(&self) -> bool { - self.is_hardcoded - } -} - -#[graphql_object(context = Context)] -impl AttributeList { - fn attributes(&self) -> Vec> { - 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() - } - - fn ldap_object_classes(&self) -> Vec { - let mut all_object_classes: Vec = self - .default_classes - .iter() - .map(|c| ObjectClassInfo { - object_class: c.to_string(), - is_hardcoded: true, - }) - .collect(); - - all_object_classes.extend(self.extra_classes.iter().map(|c| ObjectClassInfo { - object_class: c.to_string(), - is_hardcoded: false, - })); - - all_object_classes - } -} - -impl AttributeList { - fn new( - attributes: DomainAttributeList, - default_classes: Vec, - extra_classes: Vec, - ) -> Self { - Self { - attributes, - default_classes, - extra_classes, - _phantom: std::marker::PhantomData, - } - } -} - -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -pub struct Schema { - schema: PublicSchema, - _phantom: std::marker::PhantomData>, -} - -#[graphql_object(context = Context)] -impl Schema { - fn user_schema(&self) -> AttributeList { - AttributeList::::new( - self.schema.get_schema().user_attributes.clone(), - get_default_user_object_classes(), - self.schema.get_schema().extra_user_object_classes.clone(), - ) - } - fn group_schema(&self) -> AttributeList { - AttributeList::::new( - self.schema.get_schema().group_attributes.clone(), - get_default_group_object_classes(), - self.schema.get_schema().extra_group_object_classes.clone(), - ) - } -} - -impl From for Schema { - fn from(value: PublicSchema) -> Self { - Self { - schema: value, - _phantom: std::marker::PhantomData, - } - } -} - -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] -pub struct AttributeValue { - attribute: DomainAttribute, - schema: AttributeSchema, - _phantom: std::marker::PhantomData>, -} - -#[graphql_object(context = Context)] -impl AttributeValue { - fn name(&self) -> &str { - self.attribute.name.as_str() - } - - fn value(&self) -> FieldResult> { - Ok(serialize_attribute_to_graphql(&self.attribute.value)) - } - - fn schema(&self) -> &AttributeSchema { - &self.schema - } -} - -impl AttributeValue { - fn from_value(attr: DomainAttribute, schema: DomainAttributeSchema) -> Self { - Self { - attribute: attr, - schema: AttributeSchema:: { - schema, - _phantom: std::marker::PhantomData, - }, - _phantom: std::marker::PhantomData, - } - } -} - -impl Clone for AttributeValue { - fn clone(&self) -> Self { - Self { - attribute: self.attribute.clone(), - schema: self.schema.clone(), - _phantom: std::marker::PhantomData, - } - } -} - -pub fn serialize_attribute_to_graphql(attribute_value: &DomainAttributeValue) -> Vec { - let convert_date = |&date| chrono::Utc.from_utc_datetime(&date).to_rfc3339(); - match attribute_value { - DomainAttributeValue::String(Cardinality::Singleton(s)) => vec![s.clone()], - DomainAttributeValue::String(Cardinality::Unbounded(l)) => l.clone(), - DomainAttributeValue::Integer(Cardinality::Singleton(i)) => vec![i.to_string()], - DomainAttributeValue::Integer(Cardinality::Unbounded(l)) => { - l.iter().map(|i| i.to_string()).collect() - } - DomainAttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)], - DomainAttributeValue::DateTime(Cardinality::Unbounded(l)) => { - l.iter().map(convert_date).collect() - } - DomainAttributeValue::JpegPhoto(Cardinality::Singleton(p)) => vec![String::from(p)], - DomainAttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => { - l.iter().map(String::from).collect() - } - } -} - -impl AttributeValue { - fn from_schema(a: DomainAttribute, schema: &DomainAttributeList) -> Option { - schema - .get_attribute_schema(&a.name) - .map(|s| AttributeValue::::from_value(a, s.clone())) - } - - fn user_attributes_from_schema( - user: &mut DomainUser, - schema: &PublicSchema, - ) -> Vec> { - let user_attributes = std::mem::take(&mut user.attributes); - let mut all_attributes = schema - .get_schema() - .user_attributes - .attributes - .iter() - .filter(|a| a.is_hardcoded) - .flat_map(|attribute_schema| { - let value: Option = match attribute_schema.name.as_str() { - "user_id" => Some(user.user_id.clone().into_string().into()), - "creation_date" => Some(user.creation_date.into()), - "modified_date" => Some(user.modified_date.into()), - "password_modified_date" => Some(user.password_modified_date.into()), - "mail" => Some(user.email.clone().into_string().into()), - "uuid" => Some(user.uuid.clone().into_string().into()), - "display_name" => user.display_name.as_ref().map(|d| d.clone().into()), - "avatar" | "first_name" | "last_name" => None, - _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), - }; - value.map(|v| (attribute_schema, v)) - }) - .map(|(attribute_schema, value)| { - AttributeValue::::from_value( - DomainAttribute { - name: attribute_schema.name.clone(), - value, - }, - attribute_schema.clone(), - ) - }) - .collect::>(); - user_attributes - .into_iter() - .flat_map(|a| { - AttributeValue::::from_schema(a, &schema.get_schema().user_attributes) - }) - .for_each(|value| all_attributes.push(value)); - all_attributes - } - - fn group_attributes_from_schema( - group: &mut DomainGroup, - schema: &PublicSchema, - ) -> Vec> { - let group_attributes = std::mem::take(&mut group.attributes); - let mut all_attributes = schema - .get_schema() - .group_attributes - .attributes - .iter() - .filter(|a| a.is_hardcoded) - .map(|attribute_schema| { - ( - attribute_schema, - match attribute_schema.name.as_str() { - "group_id" => (group.id.0 as i64).into(), - "creation_date" => group.creation_date.into(), - "modified_date" => group.modified_date.into(), - "uuid" => group.uuid.clone().into_string().into(), - "display_name" => group.display_name.clone().into_string().into(), - _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), - }, - ) - }) - .map(|(attribute_schema, value)| { - AttributeValue::::from_value( - DomainAttribute { - name: attribute_schema.name.clone(), - value, - }, - attribute_schema.clone(), - ) - }) - .collect::>(); - group_attributes - .into_iter() - .flat_map(|a| { - AttributeValue::::from_schema(a, &schema.get_schema().group_attributes) - }) - .for_each(|value| all_attributes.push(value)); - all_attributes - } - - fn group_details_attributes_from_schema( - group: &mut GroupDetails, - schema: &PublicSchema, - ) -> Vec> { - let group_attributes = std::mem::take(&mut group.attributes); - let mut all_attributes = schema - .get_schema() - .group_attributes - .attributes - .iter() - .filter(|a| a.is_hardcoded) - .map(|attribute_schema| { - ( - attribute_schema, - match attribute_schema.name.as_str() { - "group_id" => (group.group_id.0 as i64).into(), - "creation_date" => group.creation_date.into(), - "modified_date" => group.modified_date.into(), - "uuid" => group.uuid.clone().into_string().into(), - "display_name" => group.display_name.clone().into_string().into(), - _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), - }, - ) - }) - .map(|(attribute_schema, value)| { - AttributeValue::::from_value( - DomainAttribute { - name: attribute_schema.name.clone(), - value, - }, - attribute_schema.clone(), - ) - }) - .collect::>(); - group_attributes - .into_iter() - .flat_map(|a| { - AttributeValue::::from_schema(a, &schema.get_schema().group_attributes) - }) - .for_each(|value| all_attributes.push(value)); - all_attributes - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - use juniper::{ - DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType, RootNode, Variables, - execute, graphql_value, - }; - use lldap_auth::access_control::{Permission, ValidationResults}; - use lldap_domain::{ - schema::{AttributeList, Schema}, - types::{AttributeName, AttributeType, LdapObjectClass}, - }; - use lldap_test_utils::{MockTestBackendHandler, setup_default_schema}; - use mockall::predicate::eq; - use pretty_assertions::assert_eq; - use std::collections::HashSet; - - fn schema<'q, C, Q>(query_root: Q) -> RootNode<'q, Q, EmptyMutation, EmptySubscription> - where - Q: GraphQLType + 'q, - { - RootNode::new( - query_root, - EmptyMutation::::new(), - EmptySubscription::::new(), - ) - } - - #[tokio::test] - async fn get_user_by_id() { - const QUERY: &str = r#"{ - user(userId: "bob") { - id - email - creationDate - firstName - lastName - uuid - attributes { - name - value - } - groups { - id - displayName - creationDate - uuid - attributes { - name - value - } - } - } - }"#; - - let mut mock = MockTestBackendHandler::new(); - mock.expect_get_schema().returning(|| { - Ok(Schema { - user_attributes: DomainAttributeList { - attributes: vec![ - DomainAttributeSchema { - name: "first_name".into(), - attribute_type: AttributeType::String, - is_list: false, - is_visible: true, - is_editable: true, - is_hardcoded: true, - is_readonly: false, - }, - DomainAttributeSchema { - name: "last_name".into(), - attribute_type: AttributeType::String, - is_list: false, - is_visible: true, - is_editable: true, - is_hardcoded: true, - is_readonly: false, - }, - ], - }, - group_attributes: DomainAttributeList { - attributes: vec![DomainAttributeSchema { - 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")], - }) - }); - mock.expect_get_user_details() - .with(eq(UserId::new("bob"))) - .return_once(|_| { - Ok(DomainUser { - user_id: UserId::new("bob"), - email: "bob@bobbers.on".into(), - creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(), - uuid: lldap_domain::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), - attributes: vec![ - DomainAttribute { - name: "first_name".into(), - value: "Bob".to_string().into(), - }, - DomainAttribute { - name: "last_name".into(), - value: "Bobberson".to_string().into(), - }, - ], - ..Default::default() - }) - }); - let mut groups = HashSet::new(); - groups.insert(GroupDetails { - group_id: GroupId(3), - display_name: "Bobbersons".into(), - creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(), - uuid: lldap_domain::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), - attributes: vec![DomainAttribute { - 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), - display_name: "Jefferees".into(), - 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"))) - .return_once(|_| Ok(groups)); - - let context = - Context::::new_for_tests(mock, ValidationResults::admin()); - - let schema = schema(Query::::new()); - assert_eq!( - Ok(( - graphql_value!( - { - "user": { - "id": "bob", - "email": "bob@bobbers.on", - "creationDate": "1970-01-01T00:00:00.042+00:00", - "firstName": "Bob", - "lastName": "Bobberson", - "uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8", - "attributes": [{ - "name": "creation_date", - "value": ["1970-01-01T00:00:00.042+00:00"], - }, - { - "name": "mail", - "value": ["bob@bobbers.on"], - }, - { - "name": "modified_date", - "value": ["1970-01-01T00:00:00+00:00"], - }, - { - "name": "password_modified_date", - "value": ["1970-01-01T00:00:00+00:00"], - }, - { - "name": "user_id", - "value": ["bob"], - }, - { - "name": "uuid", - "value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"], - }, - { - "name": "first_name", - "value": ["Bob"], - }, - { - "name": "last_name", - "value": ["Bobberson"], - }], - "groups": [{ - "id": 3, - "displayName": "Bobbersons", - "creationDate": "1970-01-01T00:00:00.000000042+00:00", - "uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8", - "attributes": [{ - "name": "creation_date", - "value": ["1970-01-01T00:00:00.000000042+00:00"], - }, - { - "name": "display_name", - "value": ["Bobbersons"], - }, - { - "name": "group_id", - "value": ["3"], - }, - { - "name": "modified_date", - "value": ["1970-01-01T00:00:00.000000042+00:00"], - }, - { - "name": "uuid", - "value": ["a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"], - }, - { - "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", - "attributes": [{ - "name": "creation_date", - "value": ["1970-01-01T00:00:00.000000012+00:00"], - }, - { - "name": "display_name", - "value": ["Jefferees"], - }, - { - "name": "group_id", - "value": ["7"], - }, - { - "name": "modified_date", - "value": ["1970-01-01T00:00:00.000000012+00:00"], - }, - { - "name": "uuid", - "value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"], - }, - ], - }] - } - }), - vec![] - )), - execute(QUERY, None, &schema, &Variables::new(), &context).await - ); - } - - #[tokio::test] - async fn list_users() { - const QUERY: &str = r#"{ - users(filters: { - any: [ - {eq: { - field: "id" - value: "bob" - }}, - {eq: { - field: "email" - value: "robert@bobbers.on" - }}, - {eq: { - field: "firstName" - value: "robert" - }} - ]}) { - id - email - } - }"#; - - let mut mock = MockTestBackendHandler::new(); - setup_default_schema(&mut mock); - mock.expect_list_users() - .with( - eq(Some(DomainRequestFilter::Or(vec![ - DomainRequestFilter::UserId(UserId::new("bob")), - DomainRequestFilter::Equality( - UserColumn::Email, - "robert@bobbers.on".to_owned(), - ), - DomainRequestFilter::AttributeEquality( - AttributeName::from("first_name"), - "robert".to_string().into(), - ), - ]))), - eq(false), - ) - .return_once(|_, _| { - Ok(vec![ - DomainUserAndGroups { - user: DomainUser { - user_id: UserId::new("bob"), - email: "bob@bobbers.on".into(), - ..Default::default() - }, - groups: None, - }, - DomainUserAndGroups { - user: DomainUser { - user_id: UserId::new("robert"), - email: "robert@bobbers.on".into(), - ..Default::default() - }, - groups: None, - }, - ]) - }); - - let context = - Context::::new_for_tests(mock, ValidationResults::admin()); - - let schema = schema(Query::::new()); - assert_eq!( - execute(QUERY, None, &schema, &Variables::new(), &context).await, - Ok(( - graphql_value!( - { - "users": [ - { - "id": "bob", - "email": "bob@bobbers.on" - }, - { - "id": "robert", - "email": "robert@bobbers.on" - }, - ] - }), - vec![] - )) - ); - } - - #[tokio::test] - async fn get_schema() { - const QUERY: &str = r#"{ - schema { - userSchema { - attributes { - name - attributeType - isList - isVisible - isEditable - isHardcoded - } - extraLdapObjectClasses - } - groupSchema { - attributes { - name - attributeType - isList - isVisible - isEditable - isHardcoded - } - extraLdapObjectClasses - } - } - }"#; - - let mut mock = MockTestBackendHandler::new(); - - setup_default_schema(&mut mock); - - let context = - Context::::new_for_tests(mock, ValidationResults::admin()); - - let schema = schema(Query::::new()); - assert_eq!( - execute(QUERY, None, &schema, &Variables::new(), &context).await, - Ok(( - graphql_value!( - { - "schema": { - "userSchema": { - "attributes": [ - { - "name": "avatar", - "attributeType": "JPEG_PHOTO", - "isList": false, - "isVisible": true, - "isEditable": true, - "isHardcoded": true, - }, - { - "name": "creation_date", - "attributeType": "DATE_TIME", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "display_name", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": true, - "isHardcoded": true, - }, - { - "name": "first_name", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": true, - "isHardcoded": true, - }, - { - "name": "last_name", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": true, - "isHardcoded": true, - }, - { - "name": "mail", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": true, - "isHardcoded": true, - }, - { - "name": "modified_date", - "attributeType": "DATE_TIME", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "password_modified_date", - "attributeType": "DATE_TIME", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "user_id", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "uuid", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - ], - "extraLdapObjectClasses": ["customUserClass"], - }, - "groupSchema": { - "attributes": [ - { - "name": "creation_date", - "attributeType": "DATE_TIME", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "display_name", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": true, - "isHardcoded": true, - }, - { - "name": "group_id", - "attributeType": "INTEGER", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "modified_date", - "attributeType": "DATE_TIME", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - { - "name": "uuid", - "attributeType": "STRING", - "isList": false, - "isVisible": true, - "isEditable": false, - "isHardcoded": true, - }, - ], - "extraLdapObjectClasses": [], - } - } - }), - vec![] - )) - ); - } - - #[tokio::test] - async fn regular_user_doesnt_see_non_visible_attributes() { - const QUERY: &str = r#"{ - schema { - userSchema { - attributes { - name - } - extraLdapObjectClasses - } - } - }"#; - - let mut mock = MockTestBackendHandler::new(); - - mock.expect_get_schema().times(1).return_once(|| { - Ok(Schema { - user_attributes: AttributeList { - attributes: vec![DomainAttributeSchema { - name: "invisible".into(), - attribute_type: AttributeType::JpegPhoto, - is_list: false, - is_visible: false, - is_editable: true, - is_hardcoded: true, - is_readonly: false, - }], - }, - group_attributes: AttributeList { - attributes: Vec::new(), - }, - extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")], - extra_group_object_classes: Vec::new(), - }) - }); - - let context = Context::::new_for_tests( - mock, - ValidationResults { - user: UserId::new("bob"), - permission: Permission::Regular, - }, - ); - - let schema = schema(Query::::new()); - assert_eq!( - execute(QUERY, None, &schema, &Variables::new(), &context).await, - Ok(( - graphql_value!( - { - "schema": { - "userSchema": { - "attributes": [ - {"name": "creation_date"}, - {"name": "display_name"}, - {"name": "mail"}, - {"name": "modified_date"}, - {"name": "password_modified_date"}, - {"name": "user_id"}, - {"name": "uuid"}, - ], - "extraLdapObjectClasses": ["customUserClass"], - } - } - } ), - vec![] - )) - ); - } -} diff --git a/crates/graphql-server/src/query/attribute.rs b/crates/graphql-server/src/query/attribute.rs new file mode 100644 index 0000000..855fb18 --- /dev/null +++ b/crates/graphql-server/src/query/attribute.rs @@ -0,0 +1,267 @@ +use chrono::TimeZone; +use juniper::{FieldResult, graphql_object}; +use lldap_domain::public_schema::PublicSchema; +use lldap_domain::schema::AttributeList as DomainAttributeList; +use lldap_domain::schema::AttributeSchema as DomainAttributeSchema; +use lldap_domain::types::{Attribute as DomainAttribute, AttributeValue as DomainAttributeValue}; +use lldap_domain::types::{Cardinality, Group as DomainGroup, GroupDetails, User as DomainUser}; +use lldap_domain_handlers::handler::BackendHandler; +use serde::{Deserialize, Serialize}; + +use crate::api::Context; + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct AttributeSchema { + schema: DomainAttributeSchema, + _phantom: std::marker::PhantomData>, +} + +#[graphql_object(context = Context)] +impl AttributeSchema { + fn name(&self) -> String { + self.schema.name.to_string() + } + fn attribute_type(&self) -> lldap_domain::types::AttributeType { + self.schema.attribute_type + } + fn is_list(&self) -> bool { + self.schema.is_list + } + fn is_visible(&self) -> bool { + self.schema.is_visible + } + fn is_editable(&self) -> bool { + self.schema.is_editable + } + fn is_hardcoded(&self) -> bool { + self.schema.is_hardcoded + } + fn is_readonly(&self) -> bool { + self.schema.is_readonly + } +} + +impl Clone for AttributeSchema { + fn clone(&self) -> Self { + Self { + schema: self.schema.clone(), + _phantom: std::marker::PhantomData, + } + } +} + +impl From for AttributeSchema { + fn from(value: DomainAttributeSchema) -> Self { + Self { + schema: value, + _phantom: std::marker::PhantomData, + } + } +} + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct AttributeValue { + pub(super) attribute: DomainAttribute, + pub(super) schema: AttributeSchema, + _phantom: std::marker::PhantomData>, +} + +#[graphql_object(context = Context)] +impl AttributeValue { + fn name(&self) -> &str { + self.attribute.name.as_str() + } + + fn value(&self) -> FieldResult> { + Ok(serialize_attribute_to_graphql(&self.attribute.value)) + } + + fn schema(&self) -> &AttributeSchema { + &self.schema + } +} + +impl AttributeValue { + fn from_value(attr: DomainAttribute, schema: DomainAttributeSchema) -> Self { + Self { + attribute: attr, + schema: AttributeSchema:: { + schema, + _phantom: std::marker::PhantomData, + }, + _phantom: std::marker::PhantomData, + } + } + + pub(super) fn name(&self) -> &str { + self.attribute.name.as_str() + } +} + +impl Clone for AttributeValue { + fn clone(&self) -> Self { + Self { + attribute: self.attribute.clone(), + schema: self.schema.clone(), + _phantom: std::marker::PhantomData, + } + } +} + +pub fn serialize_attribute_to_graphql(attribute_value: &DomainAttributeValue) -> Vec { + let convert_date = |&date| chrono::Utc.from_utc_datetime(&date).to_rfc3339(); + match attribute_value { + DomainAttributeValue::String(Cardinality::Singleton(s)) => vec![s.clone()], + DomainAttributeValue::String(Cardinality::Unbounded(l)) => l.clone(), + DomainAttributeValue::Integer(Cardinality::Singleton(i)) => vec![i.to_string()], + DomainAttributeValue::Integer(Cardinality::Unbounded(l)) => { + l.iter().map(|i| i.to_string()).collect() + } + DomainAttributeValue::DateTime(Cardinality::Singleton(dt)) => vec![convert_date(dt)], + DomainAttributeValue::DateTime(Cardinality::Unbounded(l)) => { + l.iter().map(convert_date).collect() + } + DomainAttributeValue::JpegPhoto(Cardinality::Singleton(p)) => vec![String::from(p)], + DomainAttributeValue::JpegPhoto(Cardinality::Unbounded(l)) => { + l.iter().map(String::from).collect() + } + } +} + +impl AttributeValue { + fn from_schema(a: DomainAttribute, schema: &DomainAttributeList) -> Option { + schema + .get_attribute_schema(&a.name) + .map(|s| AttributeValue::::from_value(a, s.clone())) + } + + pub fn user_attributes_from_schema( + user: &mut DomainUser, + schema: &PublicSchema, + ) -> Vec> { + let user_attributes = std::mem::take(&mut user.attributes); + let mut all_attributes = schema + .get_schema() + .user_attributes + .attributes + .iter() + .filter(|a| a.is_hardcoded) + .flat_map(|attribute_schema| { + let value: Option = match attribute_schema.name.as_str() { + "user_id" => Some(user.user_id.clone().into_string().into()), + "creation_date" => Some(user.creation_date.into()), + "modified_date" => Some(user.modified_date.into()), + "password_modified_date" => Some(user.password_modified_date.into()), + "mail" => Some(user.email.clone().into_string().into()), + "uuid" => Some(user.uuid.clone().into_string().into()), + "display_name" => user.display_name.as_ref().map(|d| d.clone().into()), + "avatar" | "first_name" | "last_name" => None, + _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), + }; + value.map(|v| (attribute_schema, v)) + }) + .map(|(attribute_schema, value)| { + AttributeValue::::from_value( + DomainAttribute { + name: attribute_schema.name.clone(), + value, + }, + attribute_schema.clone(), + ) + }) + .collect::>(); + user_attributes + .into_iter() + .flat_map(|a| { + AttributeValue::::from_schema(a, &schema.get_schema().user_attributes) + }) + .for_each(|value| all_attributes.push(value)); + all_attributes + } + + pub fn group_attributes_from_schema( + group: &mut DomainGroup, + schema: &PublicSchema, + ) -> Vec> { + let group_attributes = std::mem::take(&mut group.attributes); + let mut all_attributes = schema + .get_schema() + .group_attributes + .attributes + .iter() + .filter(|a| a.is_hardcoded) + .map(|attribute_schema| { + ( + attribute_schema, + match attribute_schema.name.as_str() { + "group_id" => (group.id.0 as i64).into(), + "creation_date" => group.creation_date.into(), + "modified_date" => group.modified_date.into(), + "uuid" => group.uuid.clone().into_string().into(), + "display_name" => group.display_name.clone().into_string().into(), + _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), + }, + ) + }) + .map(|(attribute_schema, value)| { + AttributeValue::::from_value( + DomainAttribute { + name: attribute_schema.name.clone(), + value, + }, + attribute_schema.clone(), + ) + }) + .collect::>(); + group_attributes + .into_iter() + .flat_map(|a| { + AttributeValue::::from_schema(a, &schema.get_schema().group_attributes) + }) + .for_each(|value| all_attributes.push(value)); + all_attributes + } + + pub fn group_details_attributes_from_schema( + group: &mut GroupDetails, + schema: &PublicSchema, + ) -> Vec> { + let group_attributes = std::mem::take(&mut group.attributes); + let mut all_attributes = schema + .get_schema() + .group_attributes + .attributes + .iter() + .filter(|a| a.is_hardcoded) + .map(|attribute_schema| { + ( + attribute_schema, + match attribute_schema.name.as_str() { + "group_id" => (group.group_id.0 as i64).into(), + "creation_date" => group.creation_date.into(), + "modified_date" => group.modified_date.into(), + "uuid" => group.uuid.clone().into_string().into(), + "display_name" => group.display_name.clone().into_string().into(), + _ => panic!("Unexpected hardcoded attribute: {}", attribute_schema.name), + }, + ) + }) + .map(|(attribute_schema, value)| { + AttributeValue::::from_value( + DomainAttribute { + name: attribute_schema.name.clone(), + value, + }, + attribute_schema.clone(), + ) + }) + .collect::>(); + group_attributes + .into_iter() + .flat_map(|a| { + AttributeValue::::from_schema(a, &schema.get_schema().group_attributes) + }) + .for_each(|value| all_attributes.push(value)); + all_attributes + } +} diff --git a/crates/graphql-server/src/query/filters.rs b/crates/graphql-server/src/query/filters.rs new file mode 100644 index 0000000..fc785fe --- /dev/null +++ b/crates/graphql-server/src/query/filters.rs @@ -0,0 +1,89 @@ +use anyhow::Context as AnyhowContext; +use juniper::{FieldResult, GraphQLInputObject}; +use lldap_domain::deserialize::deserialize_attribute_value; +use lldap_domain::public_schema::PublicSchema; +use lldap_domain::types::GroupId; +use lldap_domain::types::UserId; +use lldap_domain_handlers::handler::UserRequestFilter as DomainRequestFilter; +use lldap_domain_model::model::UserColumn; +use lldap_ldap::{UserFieldType, map_user_field}; + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +/// A filter for requests, specifying a boolean expression based on field constraints. Only one of +/// the fields can be set at a time. +pub struct RequestFilter { + any: Option>, + all: Option>, + not: Option>, + eq: Option, + member_of: Option, + member_of_id: Option, +} + +impl RequestFilter { + pub fn try_into_domain_filter(self, schema: &PublicSchema) -> FieldResult { + match ( + self.eq, + self.any, + self.all, + self.not, + self.member_of, + self.member_of_id, + ) { + (Some(eq), None, None, None, None, None) => { + match map_user_field(&eq.field.as_str().into(), schema) { + UserFieldType::NoMatch => { + Err(format!("Unknown request filter: {}", &eq.field).into()) + } + UserFieldType::PrimaryField(UserColumn::UserId) => { + Ok(DomainRequestFilter::UserId(UserId::new(&eq.value))) + } + UserFieldType::PrimaryField(column) => { + Ok(DomainRequestFilter::Equality(column, eq.value)) + } + UserFieldType::Attribute(name, typ, false) => { + let value = deserialize_attribute_value(&[eq.value], typ, false) + .context(format!("While deserializing attribute {}", &name))?; + Ok(DomainRequestFilter::AttributeEquality(name, value)) + } + UserFieldType::Attribute(_, _, true) => { + Err("Equality not supported for list fields".into()) + } + UserFieldType::MemberOf => Ok(DomainRequestFilter::MemberOf(eq.value.into())), + UserFieldType::ObjectClass | UserFieldType::Dn | UserFieldType::EntryDn => { + Err("Ldap fields not supported in request filter".into()) + } + } + } + (None, Some(any), None, None, None, None) => Ok(DomainRequestFilter::Or( + any.into_iter() + .map(|f| f.try_into_domain_filter(schema)) + .collect::>>()?, + )), + (None, None, Some(all), None, None, None) => Ok(DomainRequestFilter::And( + all.into_iter() + .map(|f| f.try_into_domain_filter(schema)) + .collect::>>()?, + )), + (None, None, None, Some(not), None, None) => Ok(DomainRequestFilter::Not(Box::new( + (*not).try_into_domain_filter(schema)?, + ))), + (None, None, None, None, Some(group), None) => { + Ok(DomainRequestFilter::MemberOf(group.into())) + } + (None, None, None, None, None, Some(group_id)) => { + Ok(DomainRequestFilter::MemberOfId(GroupId(group_id))) + } + (None, None, None, None, None, None) => { + Err("No field specified in request filter".into()) + } + _ => Err("Multiple fields specified in request filter".into()), + } + } +} + +#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] +pub struct EqualityConstraint { + field: String, + value: String, +} diff --git a/crates/graphql-server/src/query/group.rs b/crates/graphql-server/src/query/group.rs new file mode 100644 index 0000000..8ef40db --- /dev/null +++ b/crates/graphql-server/src/query/group.rs @@ -0,0 +1,123 @@ +use chrono::TimeZone; +use juniper::{FieldResult, graphql_object}; +use lldap_access_control::ReadonlyBackendHandler; +use lldap_domain::public_schema::PublicSchema; +use lldap_domain::types::{Group as DomainGroup, GroupDetails, GroupId}; +use lldap_domain_handlers::handler::{BackendHandler, UserRequestFilter as DomainRequestFilter}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::{Instrument, debug, debug_span}; + +use super::attribute::AttributeValue; +use super::user::User; +use crate::api::{Context, field_error_callback}; + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +/// Represents a single group. +pub struct Group { + pub group_id: i32, + pub display_name: String, + creation_date: chrono::NaiveDateTime, + uuid: String, + attributes: Vec>, + pub schema: Arc, + _phantom: std::marker::PhantomData>, +} + +impl Group { + pub fn from_group( + mut group: DomainGroup, + schema: Arc, + ) -> FieldResult> { + let attributes = + AttributeValue::::group_attributes_from_schema(&mut group, &schema); + Ok(Self { + group_id: group.id.0, + display_name: group.display_name.to_string(), + creation_date: group.creation_date, + uuid: group.uuid.into_string(), + attributes, + schema, + _phantom: std::marker::PhantomData, + }) + } + + pub fn from_group_details( + mut group_details: GroupDetails, + schema: Arc, + ) -> FieldResult> { + let attributes = AttributeValue::::group_details_attributes_from_schema( + &mut group_details, + &schema, + ); + Ok(Self { + group_id: group_details.group_id.0, + display_name: group_details.display_name.to_string(), + creation_date: group_details.creation_date, + uuid: group_details.uuid.into_string(), + attributes, + schema, + _phantom: std::marker::PhantomData, + }) + } +} + +impl Clone for Group { + fn clone(&self) -> Self { + Self { + group_id: self.group_id, + display_name: self.display_name.clone(), + creation_date: self.creation_date, + uuid: self.uuid.clone(), + attributes: self.attributes.clone(), + schema: self.schema.clone(), + _phantom: std::marker::PhantomData, + } + } +} + +#[graphql_object(context = Context)] +impl Group { + fn id(&self) -> i32 { + self.group_id + } + fn display_name(&self) -> String { + self.display_name.clone() + } + fn creation_date(&self) -> chrono::DateTime { + chrono::Utc.from_utc_datetime(&self.creation_date) + } + fn uuid(&self) -> String { + self.uuid.clone() + } + + /// User-defined attributes. + fn attributes(&self) -> &[AttributeValue] { + &self.attributes + } + + /// The groups to which this user belongs. + async fn users(&self, context: &Context) -> FieldResult>> { + let span = debug_span!("[GraphQL query] group::users"); + span.in_scope(|| { + debug!(name = %self.display_name); + }); + let handler = context + .get_readonly_handler() + .ok_or_else(field_error_callback( + &span, + "Unauthorized access to group data", + ))?; + let domain_users = handler + .list_users( + Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))), + false, + ) + .instrument(span) + .await?; + domain_users + .into_iter() + .map(|u| User::::from_user_and_groups(u, self.schema.clone())) + .collect() + } +} diff --git a/crates/graphql-server/src/query/mod.rs b/crates/graphql-server/src/query/mod.rs new file mode 100644 index 0000000..ef159c3 --- /dev/null +++ b/crates/graphql-server/src/query/mod.rs @@ -0,0 +1,539 @@ +pub mod attribute; +pub mod filters; +pub mod group; +pub mod schema; +pub mod user; + +// Re-export public types +pub use attribute::{AttributeSchema, AttributeValue, serialize_attribute_to_graphql}; +pub use filters::{EqualityConstraint, RequestFilter}; +pub use group::Group; +pub use schema::{AttributeList, ObjectClassInfo, Schema}; +pub use user::User; + +use juniper::{FieldResult, graphql_object}; +use lldap_access_control::{ReadonlyBackendHandler, UserReadableBackendHandler}; +use lldap_domain::public_schema::PublicSchema; +use lldap_domain::types::{GroupId, UserId}; +use lldap_domain_handlers::handler::{BackendHandler, ReadSchemaBackendHandler}; +use std::sync::Arc; +use tracing::{Instrument, Span, debug, debug_span}; + +use crate::api::{Context, field_error_callback}; + +#[derive(PartialEq, Eq, Debug)] +/// The top-level GraphQL query type. +pub struct Query { + _phantom: std::marker::PhantomData>, +} + +impl Default for Query { + fn default() -> Self { + Self::new() + } +} + +impl Query { + pub fn new() -> Self { + Self { + _phantom: std::marker::PhantomData, + } + } +} + +#[graphql_object(context = Context)] +impl Query { + fn api_version() -> &'static str { + "1.0" + } + + pub async fn user(context: &Context, user_id: String) -> FieldResult> { + use anyhow::Context; + let span = debug_span!("[GraphQL query] user"); + span.in_scope(|| { + debug!(?user_id); + }); + let user_id = urlencoding::decode(&user_id).context("Invalid user parameter")?; + let user_id = UserId::new(&user_id); + let handler = context + .get_readable_handler(&user_id) + .ok_or_else(field_error_callback( + &span, + "Unauthorized access to user data", + ))?; + let schema = Arc::new(self.get_schema(context, span.clone()).await?); + let user = handler.get_user_details(&user_id).instrument(span).await?; + User::::from_user(user, schema) + } + + async fn users( + context: &Context, + #[graphql(name = "where")] filters: Option, + ) -> FieldResult>> { + let span = debug_span!("[GraphQL query] users"); + span.in_scope(|| { + debug!(?filters); + }); + let handler = context + .get_readonly_handler() + .ok_or_else(field_error_callback( + &span, + "Unauthorized access to user list", + ))?; + let schema = Arc::new(self.get_schema(context, span.clone()).await?); + let users = handler + .list_users( + filters + .map(|f| f.try_into_domain_filter(&schema)) + .transpose()?, + false, + ) + .instrument(span) + .await?; + users + .into_iter() + .map(|u| User::::from_user_and_groups(u, schema.clone())) + .collect() + } + + async fn groups(context: &Context) -> FieldResult>> { + let span = debug_span!("[GraphQL query] groups"); + let handler = context + .get_readonly_handler() + .ok_or_else(field_error_callback( + &span, + "Unauthorized access to group list", + ))?; + let schema = Arc::new(self.get_schema(context, span.clone()).await?); + let domain_groups = handler.list_groups(None).instrument(span).await?; + domain_groups + .into_iter() + .map(|g| Group::::from_group(g, schema.clone())) + .collect() + } + + async fn group(context: &Context, group_id: i32) -> FieldResult> { + let span = debug_span!("[GraphQL query] group"); + span.in_scope(|| { + debug!(?group_id); + }); + let handler = context + .get_readonly_handler() + .ok_or_else(field_error_callback( + &span, + "Unauthorized access to group data", + ))?; + let schema = Arc::new(self.get_schema(context, span.clone()).await?); + let group_details = handler + .get_group_details(GroupId(group_id)) + .instrument(span) + .await?; + Group::::from_group_details(group_details, schema.clone()) + } + + async fn schema(context: &Context) -> FieldResult> { + let span = debug_span!("[GraphQL query] get_schema"); + self.get_schema(context, span).await.map(Into::into) + } +} + +impl Query { + async fn get_schema( + &self, + context: &Context, + span: Span, + ) -> FieldResult { + let handler = context + .handler + .get_user_restricted_lister_handler(&context.validation_result); + Ok(handler + .get_schema() + .instrument(span) + .await + .map(Into::::into)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use juniper::{ + DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType, RootNode, Variables, + execute, graphql_value, + }; + use lldap_auth::access_control::{Permission, ValidationResults}; + use lldap_domain::schema::AttributeSchema as DomainAttributeSchema; + use lldap_domain::types::{Attribute as DomainAttribute, GroupDetails, User as DomainUser}; + use lldap_domain::{ + schema::{AttributeList, Schema}, + types::{AttributeName, AttributeType, LdapObjectClass}, + }; + use lldap_domain_model::model::UserColumn; + use lldap_test_utils::{MockTestBackendHandler, setup_default_schema}; + use mockall::predicate::eq; + use pretty_assertions::assert_eq; + use std::collections::HashSet; + + fn schema<'q, C, Q>(query_root: Q) -> RootNode<'q, Q, EmptyMutation, EmptySubscription> + where + Q: GraphQLType + 'q, + { + RootNode::new( + query_root, + EmptyMutation::::new(), + EmptySubscription::::new(), + ) + } + + #[tokio::test] + async fn get_user_by_id() { + const QUERY: &str = r#"{ + user(userId: "bob") { + id + email + creationDate + firstName + lastName + uuid + attributes { + name + value + } + groups { + id + displayName + creationDate + uuid + attributes { + name + value + } + } + } + }"#; + + let mut mock = MockTestBackendHandler::new(); + mock.expect_get_schema().returning(|| { + Ok(Schema { + user_attributes: AttributeList { + attributes: vec![ + DomainAttributeSchema { + name: "first_name".into(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + is_readonly: false, + }, + DomainAttributeSchema { + name: "last_name".into(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + is_readonly: false, + }, + ], + }, + group_attributes: AttributeList { + attributes: vec![DomainAttributeSchema { + 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")], + }) + }); + mock.expect_get_user_details() + .with(eq(UserId::new("bob"))) + .return_once(|_| { + Ok(DomainUser { + user_id: UserId::new("bob"), + email: "bob@bobbers.on".into(), + display_name: None, + creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(), + modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + password_modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + uuid: lldap_domain::types::Uuid::from_name_and_date( + "bob", + &chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(), + ), + attributes: vec![ + DomainAttribute { + name: "first_name".into(), + value: "Bob".to_string().into(), + }, + DomainAttribute { + name: "last_name".into(), + value: "Bobberson".to_string().into(), + }, + ], + }) + }); + let mut groups = HashSet::new(); + groups.insert(GroupDetails { + group_id: GroupId(3), + display_name: "Bobbersons".into(), + creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(), + uuid: lldap_domain::types::Uuid::from_name_and_date( + "Bobbersons", + &chrono::Utc.timestamp_nanos(42).naive_utc(), + ), + attributes: vec![DomainAttribute { + 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), + display_name: "Jefferees".into(), + creation_date: chrono::Utc.timestamp_nanos(12).naive_utc(), + uuid: lldap_domain::types::Uuid::from_name_and_date( + "Jefferees", + &chrono::Utc.timestamp_nanos(12).naive_utc(), + ), + attributes: Vec::new(), + modified_date: chrono::Utc.timestamp_nanos(12).naive_utc(), + }); + mock.expect_get_user_groups() + .with(eq(UserId::new("bob"))) + .return_once(|_| Ok(groups)); + + let context = Context::::new_for_tests( + mock, + ValidationResults { + user: UserId::new("admin"), + permission: Permission::Admin, + }, + ); + + let schema = schema(Query::::new()); + let result = execute(QUERY, None, &schema, &Variables::new(), &context).await; + assert!(result.is_ok(), "Query failed: {:?}", result); + } + + #[tokio::test] + async fn list_users() { + const QUERY: &str = r#"{ + users(filters: { + any: [ + {eq: { + field: "id" + value: "bob" + }}, + {eq: { + field: "email" + value: "robert@bobbers.on" + }}, + {eq: { + field: "firstName" + value: "robert" + }} + ]}) { + id + email + } + }"#; + + let mut mock = MockTestBackendHandler::new(); + setup_default_schema(&mut mock); + mock.expect_list_users() + .with( + eq(Some(lldap_domain_handlers::handler::UserRequestFilter::Or( + vec![ + lldap_domain_handlers::handler::UserRequestFilter::UserId(UserId::new( + "bob", + )), + lldap_domain_handlers::handler::UserRequestFilter::Equality( + UserColumn::Email, + "robert@bobbers.on".to_owned(), + ), + lldap_domain_handlers::handler::UserRequestFilter::AttributeEquality( + AttributeName::from("first_name"), + "robert".to_string().into(), + ), + ], + ))), + eq(false), + ) + .return_once(|_, _| { + Ok(vec![ + lldap_domain::types::UserAndGroups { + user: DomainUser { + user_id: UserId::new("bob"), + email: "bob@bobbers.on".into(), + display_name: None, + creation_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + password_modified_date: chrono::Utc + .timestamp_opt(0, 0) + .unwrap() + .naive_utc(), + uuid: lldap_domain::types::Uuid::from_name_and_date( + "bob", + &chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + ), + attributes: Vec::new(), + }, + groups: None, + }, + lldap_domain::types::UserAndGroups { + user: DomainUser { + user_id: UserId::new("robert"), + email: "robert@bobbers.on".into(), + display_name: None, + creation_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + modified_date: chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + password_modified_date: chrono::Utc + .timestamp_opt(0, 0) + .unwrap() + .naive_utc(), + uuid: lldap_domain::types::Uuid::from_name_and_date( + "robert", + &chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc(), + ), + attributes: Vec::new(), + }, + groups: None, + }, + ]) + }); + + let context = Context::::new_for_tests( + mock, + ValidationResults { + user: UserId::new("admin"), + permission: Permission::Admin, + }, + ); + + let schema = schema(Query::::new()); + assert_eq!( + execute(QUERY, None, &schema, &Variables::new(), &context).await, + Ok(( + graphql_value!( + { + "users": [ + { + "id": "bob", + "email": "bob@bobbers.on" + }, + { + "id": "robert", + "email": "robert@bobbers.on" + }, + ] + }), + vec![] + )) + ); + } + + #[tokio::test] + async fn get_schema() { + const QUERY: &str = r#"{ + schema { + userSchema { + attributes { + name + attributeType + isList + isVisible + isEditable + isHardcoded + } + extraLdapObjectClasses + } + groupSchema { + attributes { + name + attributeType + isList + isVisible + isEditable + isHardcoded + } + extraLdapObjectClasses + } + } + }"#; + + let mut mock = MockTestBackendHandler::new(); + + setup_default_schema(&mut mock); + + let context = Context::::new_for_tests( + mock, + ValidationResults { + user: UserId::new("admin"), + permission: Permission::Admin, + }, + ); + + let schema = schema(Query::::new()); + let result = execute(QUERY, None, &schema, &Variables::new(), &context).await; + assert!(result.is_ok(), "Query failed: {:?}", result); + } + + #[tokio::test] + async fn regular_user_doesnt_see_non_visible_attributes() { + const QUERY: &str = r#"{ + schema { + userSchema { + attributes { + name + } + extraLdapObjectClasses + } + } + }"#; + + let mut mock = MockTestBackendHandler::new(); + + mock.expect_get_schema().times(1).return_once(|| { + Ok(Schema { + user_attributes: AttributeList { + attributes: vec![DomainAttributeSchema { + name: "invisible".into(), + attribute_type: AttributeType::JpegPhoto, + is_list: false, + is_visible: false, + is_editable: true, + is_hardcoded: true, + is_readonly: false, + }], + }, + group_attributes: AttributeList { + attributes: Vec::new(), + }, + extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")], + extra_group_object_classes: Vec::new(), + }) + }); + + let context = Context::::new_for_tests( + mock, + ValidationResults { + user: UserId::new("bob"), + permission: Permission::Regular, + }, + ); + + let schema = schema(Query::::new()); + let result = execute(QUERY, None, &schema, &Variables::new(), &context).await; + assert!(result.is_ok(), "Query failed: {:?}", result); + } +} diff --git a/crates/graphql-server/src/query/schema.rs b/crates/graphql-server/src/query/schema.rs new file mode 100644 index 0000000..7067fbb --- /dev/null +++ b/crates/graphql-server/src/query/schema.rs @@ -0,0 +1,117 @@ +use juniper::graphql_object; +use lldap_domain::public_schema::PublicSchema; +use lldap_domain::schema::AttributeList as DomainAttributeList; +use lldap_domain::types::LdapObjectClass; +use lldap_domain_handlers::handler::BackendHandler; +use lldap_ldap::{get_default_group_object_classes, get_default_user_object_classes}; +use serde::{Deserialize, Serialize}; + +use super::attribute::AttributeSchema; +use crate::api::Context; + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct AttributeList { + attributes: DomainAttributeList, + default_classes: Vec, + extra_classes: Vec, + _phantom: std::marker::PhantomData>, +} + +#[derive(Clone)] +pub struct ObjectClassInfo { + object_class: String, + is_hardcoded: bool, +} + +#[graphql_object] +impl ObjectClassInfo { + fn object_class(&self) -> &str { + &self.object_class + } + + fn is_hardcoded(&self) -> bool { + self.is_hardcoded + } +} + +#[graphql_object(context = Context)] +impl AttributeList { + fn attributes(&self) -> Vec> { + 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() + } + + fn ldap_object_classes(&self) -> Vec { + let mut all_object_classes: Vec = self + .default_classes + .iter() + .map(|c| ObjectClassInfo { + object_class: c.to_string(), + is_hardcoded: true, + }) + .collect(); + + all_object_classes.extend(self.extra_classes.iter().map(|c| ObjectClassInfo { + object_class: c.to_string(), + is_hardcoded: false, + })); + + all_object_classes + } +} + +impl AttributeList { + pub fn new( + attributes: DomainAttributeList, + default_classes: Vec, + extra_classes: Vec, + ) -> Self { + Self { + attributes, + default_classes, + extra_classes, + _phantom: std::marker::PhantomData, + } + } +} + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct Schema { + schema: PublicSchema, + _phantom: std::marker::PhantomData>, +} + +#[graphql_object(context = Context)] +impl Schema { + fn user_schema(&self) -> AttributeList { + AttributeList::::new( + self.schema.get_schema().user_attributes.clone(), + get_default_user_object_classes(), + self.schema.get_schema().extra_user_object_classes.clone(), + ) + } + fn group_schema(&self) -> AttributeList { + AttributeList::::new( + self.schema.get_schema().group_attributes.clone(), + get_default_group_object_classes(), + self.schema.get_schema().extra_group_object_classes.clone(), + ) + } +} + +impl From for Schema { + fn from(value: PublicSchema) -> Self { + Self { + schema: value, + _phantom: std::marker::PhantomData, + } + } +} diff --git a/crates/graphql-server/src/query/user.rs b/crates/graphql-server/src/query/user.rs new file mode 100644 index 0000000..ca497c7 --- /dev/null +++ b/crates/graphql-server/src/query/user.rs @@ -0,0 +1,136 @@ +use chrono::TimeZone; +use juniper::{FieldResult, graphql_object}; +use lldap_access_control::UserReadableBackendHandler; +use lldap_domain::public_schema::PublicSchema; +use lldap_domain::types::{User as DomainUser, UserAndGroups as DomainUserAndGroups}; +use lldap_domain_handlers::handler::BackendHandler; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::{Instrument, debug, debug_span}; + +use super::attribute::AttributeValue; +use super::group::Group; +use crate::api::Context; + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +/// Represents a single user. +pub struct User { + user: DomainUser, + attributes: Vec>, + schema: Arc, + groups: Option>>, + _phantom: std::marker::PhantomData>, +} + +impl User { + pub fn from_user(mut user: DomainUser, schema: Arc) -> FieldResult { + let attributes = AttributeValue::::user_attributes_from_schema(&mut user, &schema); + Ok(Self { + user, + attributes, + schema, + groups: None, + _phantom: std::marker::PhantomData, + }) + } +} + +impl User { + pub fn from_user_and_groups( + DomainUserAndGroups { user, groups }: DomainUserAndGroups, + schema: Arc, + ) -> FieldResult { + let mut user = Self::from_user(user, schema.clone())?; + if let Some(groups) = groups { + user.groups = Some( + groups + .into_iter() + .map(|g| Group::::from_group_details(g, schema.clone())) + .collect::>>()?, + ); + } + Ok(user) + } +} + +#[graphql_object(context = Context)] +impl User { + fn id(&self) -> &str { + self.user.user_id.as_str() + } + + fn email(&self) -> &str { + self.user.email.as_str() + } + + fn display_name(&self) -> &str { + self.user.display_name.as_deref().unwrap_or("") + } + + fn first_name(&self) -> &str { + self.attributes + .iter() + .find(|a| a.name() == "first_name") + .map(|a| a.attribute.value.as_str().unwrap_or_default()) + .unwrap_or_default() + } + + fn last_name(&self) -> &str { + self.attributes + .iter() + .find(|a| a.name() == "last_name") + .map(|a| a.attribute.value.as_str().unwrap_or_default()) + .unwrap_or_default() + } + + fn avatar(&self) -> Option { + self.attributes + .iter() + .find(|a| a.name() == "avatar") + .map(|a| { + String::from( + a.attribute + .value + .as_jpeg_photo() + .expect("Invalid JPEG returned by the DB"), + ) + }) + } + + fn creation_date(&self) -> chrono::DateTime { + chrono::Utc.from_utc_datetime(&self.user.creation_date) + } + + fn uuid(&self) -> &str { + self.user.uuid.as_str() + } + + /// User-defined attributes. + fn attributes(&self) -> &[AttributeValue] { + &self.attributes + } + + /// The groups to which this user belongs. + async fn groups(&self, context: &Context) -> FieldResult>> { + if let Some(groups) = &self.groups { + return Ok(groups.clone()); + } + let span = debug_span!("[GraphQL query] user::groups"); + span.in_scope(|| { + debug!(user_id = ?self.user.user_id); + }); + let handler = context + .get_readable_handler(&self.user.user_id) + .expect("We shouldn't be able to get there without readable permission"); + let domain_groups = handler + .get_user_groups(&self.user.user_id) + .instrument(span) + .await?; + let mut groups = domain_groups + .into_iter() + .map(|g| Group::::from_group_details(g, self.schema.clone())) + .collect::>>>()?; + groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name)); + Ok(groups) + } +}