From 31a8ba24a0e963d960c894e3070699bd4903deb4 Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Tue, 4 Jul 2023 17:29:06 +0200 Subject: [PATCH] server,graphql: Add a GraphQL method to get the schema --- schema.graphql | 19 ++ server/src/domain/handler.rs | 43 ---- server/src/infra/access_control.rs | 21 +- server/src/infra/graphql/query.rs | 330 ++++++++++++++++++++++++++++- server/src/infra/ldap_handler.rs | 100 +-------- server/src/infra/mod.rs | 4 + server/src/infra/schema.rs | 104 +++++++++ server/src/infra/test_utils.rs | 100 +++++++++ 8 files changed, 570 insertions(+), 151 deletions(-) create mode 100644 server/src/infra/schema.rs create mode 100644 server/src/infra/test_utils.rs diff --git a/schema.graphql b/schema.graphql index 4c88bd7..9008599 100644 --- a/schema.graphql +++ b/schema.graphql @@ -39,6 +39,11 @@ input RequestFilter { "DateTime" scalar DateTimeUtc +type Schema { + userSchema: AttributeList! + groupSchema: AttributeList! +} + "The fields that can be updated for a group." input UpdateGroupInput { id: Int! @@ -51,6 +56,7 @@ type Query { users(filters: RequestFilter): [User!]! groups: [Group!]! group(groupId: Int!): Group! + schema: Schema! } "The details required to create a user." @@ -76,6 +82,19 @@ type User { groups: [Group!]! } +type AttributeList { + attributes: [AttributeSchema!]! +} + +type AttributeSchema { + name: String! + attributeType: String! + isList: Boolean! + isVisible: Boolean! + isEditable: Boolean! + isHardcoded: Boolean! +} + type Success { ok: Boolean! } diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs index 767ed31..0c39431 100644 --- a/server/src/domain/handler.rs +++ b/server/src/domain/handler.rs @@ -209,49 +209,6 @@ pub trait BackendHandler: { } -#[cfg(test)] -mockall::mock! { - pub TestBackendHandler{} - impl Clone for TestBackendHandler { - fn clone(&self) -> Self; - } - #[async_trait] - impl GroupListerBackendHandler for TestBackendHandler { - async fn list_groups(&self, filters: Option) -> Result>; - } - #[async_trait] - impl GroupBackendHandler for TestBackendHandler { - async fn get_group_details(&self, group_id: GroupId) -> Result; - async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>; - async fn create_group(&self, group_name: &str) -> Result; - async fn delete_group(&self, group_id: GroupId) -> Result<()>; - } - #[async_trait] - impl UserListerBackendHandler for TestBackendHandler { - async fn list_users(&self, filters: Option, get_groups: bool) -> Result>; - } - #[async_trait] - impl UserBackendHandler for TestBackendHandler { - async fn get_user_details(&self, user_id: &UserId) -> Result; - async fn create_user(&self, request: CreateUserRequest) -> Result<()>; - async fn update_user(&self, request: UpdateUserRequest) -> Result<()>; - async fn delete_user(&self, user_id: &UserId) -> Result<()>; - async fn get_user_groups(&self, user_id: &UserId) -> Result>; - async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>; - async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>; - } - #[async_trait] - impl SchemaBackendHandler for TestBackendHandler { - async fn get_schema(&self) -> Result; - } - #[async_trait] - impl BackendHandler for TestBackendHandler {} - #[async_trait] - impl LoginHandler for TestBackendHandler { - async fn bind(&self, request: BindRequest) -> Result<()>; - } -} - #[cfg(test)] mod tests { use base64::Engine; diff --git a/server/src/infra/access_control.rs b/server/src/infra/access_control.rs index 74d193a..6b6560b 100644 --- a/server/src/infra/access_control.rs +++ b/server/src/infra/access_control.rs @@ -6,9 +6,10 @@ use tracing::info; use crate::domain::{ error::Result, handler::{ - BackendHandler, CreateUserRequest, GroupBackendHandler, GroupListerBackendHandler, - GroupRequestFilter, Schema, SchemaBackendHandler, UpdateGroupRequest, UpdateUserRequest, - UserBackendHandler, UserListerBackendHandler, UserRequestFilter, + AttributeSchema, BackendHandler, CreateUserRequest, GroupBackendHandler, + GroupListerBackendHandler, GroupRequestFilter, Schema, SchemaBackendHandler, + UpdateGroupRequest, UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, + UserRequestFilter, }, types::{Group, GroupDetails, GroupId, User, UserAndGroups, UserId}, }; @@ -73,7 +74,6 @@ impl ValidationResults { pub trait UserReadableBackendHandler { 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] @@ -113,9 +113,6 @@ 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 { - ::get_schema(self).await - } } #[async_trait] @@ -272,7 +269,15 @@ impl<'a, Handler: SchemaBackendHandler + Sync> SchemaBackendHandler for UserRestrictedListerBackendHandler<'a, Handler> { async fn get_schema(&self) -> Result { - self.handler.get_schema().await + let mut schema = self.handler.get_schema().await?; + if self.user_filter.is_some() { + let filter_attributes = |attributes: &mut Vec| { + attributes.retain(|a| a.is_visible); + }; + filter_attributes(&mut schema.user_attributes.attributes); + filter_attributes(&mut schema.group_attributes.attributes); + } + Ok(schema) } } diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs index a876825..6f04389 100644 --- a/server/src/infra/graphql/query.rs +++ b/server/src/infra/graphql/query.rs @@ -1,12 +1,13 @@ use crate::{ domain::{ - handler::BackendHandler, + handler::{BackendHandler, SchemaBackendHandler}, ldap::utils::{map_user_field, UserFieldType}, types::{GroupDetails, GroupId, JpegPhoto, UserColumn, UserId}, }, infra::{ access_control::{ReadonlyBackendHandler, UserReadableBackendHandler}, graphql::api::field_error_callback, + schema::PublicSchema, }, }; use chrono::TimeZone; @@ -18,6 +19,9 @@ type DomainRequestFilter = crate::domain::handler::UserRequestFilter; type DomainUser = crate::domain::types::User; type DomainGroup = crate::domain::types::Group; type DomainUserAndGroups = crate::domain::types::UserAndGroups; +type DomainSchema = crate::infra::schema::PublicSchema; +type DomainAttributeList = crate::domain::handler::AttributeList; +type DomainAttributeSchema = crate::domain::handler::AttributeSchema; use super::api::Context; #[derive(PartialEq, Eq, Debug, GraphQLInputObject)] @@ -202,6 +206,19 @@ impl Query { .await .map(Into::into)?) } + + async fn schema(context: &Context) -> FieldResult> { + let span = debug_span!("[GraphQL query] get_schema"); + let handler = context + .handler + .get_user_restricted_lister_handler(&context.validation_result); + Ok(handler + .get_schema() + .instrument(span) + .await + .map(Into::::into) + .map(Into::into)?) + } } #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] @@ -378,11 +395,105 @@ impl From for Group { } } +#[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.clone() + } + fn attribute_type(&self) -> String { + let name: &'static str = self.schema.attribute_type.into(); + name.to_owned() + } + 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 + } +} + +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 { + schema: DomainAttributeList, + _phantom: std::marker::PhantomData>, +} + +#[graphql_object(context = Context)] +impl AttributeList { + fn attributes(&self) -> Vec> { + self.schema + .attributes + .clone() + .into_iter() + .map(Into::into) + .collect() + } +} + +impl From for AttributeList { + fn from(value: DomainAttributeList) -> Self { + Self { + schema: value, + _phantom: std::marker::PhantomData, + } + } +} + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct Schema { + schema: DomainSchema, + _phantom: std::marker::PhantomData>, +} + +#[graphql_object(context = Context)] +impl Schema { + fn user_schema(&self) -> AttributeList { + self.schema.get_schema().user_attributes.clone().into() + } + fn group_schema(&self) -> AttributeList { + self.schema.get_schema().group_attributes.clone().into() + } +} + +impl From for Schema { + fn from(value: DomainSchema) -> Self { + Self { + schema: value, + _phantom: std::marker::PhantomData, + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::{ - domain::handler::MockTestBackendHandler, infra::access_control::ValidationResults, + domain::{handler::AttributeList, types::AttributeType}, + infra::{ + access_control::{Permission, ValidationResults}, + test_utils::{setup_default_schema, MockTestBackendHandler}, + }, }; use chrono::TimeZone; use juniper::{ @@ -552,4 +663,219 @@ mod tests { )) ); } + + #[tokio::test] + async fn get_schema() { + const QUERY: &str = r#"{ + schema { + userSchema { + attributes { + name + attributeType + isList + isVisible + isEditable + isHardcoded + } + } + groupSchema { + attributes { + name + attributeType + isList + isVisible + isEditable + isHardcoded + } + } + } + }"#; + + 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": "JpegPhoto", + "isList": false, + "isVisible": true, + "isEditable": true, + "isHardcoded": true, + }, + { + "name": "creation_date", + "attributeType": "DateTime", + "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": "user_id", + "attributeType": "String", + "isList": false, + "isVisible": true, + "isEditable": false, + "isHardcoded": true, + }, + { + "name": "uuid", + "attributeType": "String", + "isList": false, + "isVisible": true, + "isEditable": false, + "isHardcoded": true, + }, + ] + }, + "groupSchema": { + "attributes": [ + { + "name": "creation_date", + "attributeType": "DateTime", + "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": "uuid", + "attributeType": "String", + "isList": false, + "isVisible": true, + "isEditable": false, + "isHardcoded": true, + }, + ] + } + } + }), + vec![] + )) + ); + } + + #[tokio::test] + async fn regular_user_doesnt_see_non_visible_attributes() { + const QUERY: &str = r#"{ + schema { + userSchema { + attributes { + name + } + } + } + }"#; + + let mut mock = MockTestBackendHandler::new(); + + mock.expect_get_schema().times(1).return_once(|| { + Ok(crate::domain::handler::Schema { + user_attributes: AttributeList { + attributes: vec![crate::domain::handler::AttributeSchema { + name: "invisible".to_owned(), + attribute_type: AttributeType::JpegPhoto, + is_list: false, + is_visible: false, + is_editable: true, + is_hardcoded: true, + }], + }, + group_attributes: AttributeList { + attributes: 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": "user_id"}, + {"name": "uuid"}, + ] + } + } + } ), + vec![] + )) + ); + } } diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 510a162..4317d7b 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -671,74 +671,16 @@ impl LdapHandler Self; - } - #[async_trait] - impl LoginHandler for TestBackendHandler { - async fn bind(&self, request: BindRequest) -> Result<()>; - } - #[async_trait] - impl GroupListerBackendHandler for TestBackendHandler { - async fn list_groups(&self, filters: Option) -> Result>; - } - #[async_trait] - impl GroupBackendHandler for TestBackendHandler { - async fn get_group_details(&self, group_id: GroupId) -> Result; - async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>; - async fn create_group(&self, group_name: &str) -> Result; - async fn delete_group(&self, group_id: GroupId) -> Result<()>; - } - #[async_trait] - impl UserListerBackendHandler for TestBackendHandler { - async fn list_users(&self, filters: Option, get_groups: bool) -> Result>; - } - #[async_trait] - impl UserBackendHandler for TestBackendHandler { - async fn get_user_details(&self, user_id: &UserId) -> Result; - async fn create_user(&self, request: CreateUserRequest) -> Result<()>; - async fn update_user(&self, request: UpdateUserRequest) -> Result<()>; - async fn delete_user(&self, user_id: &UserId) -> Result<()>; - async fn get_user_groups(&self, user_id: &UserId) -> Result>; - async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>; - async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>; - } - #[async_trait] - impl SchemaBackendHandler for TestBackendHandler { - async fn get_schema(&self) -> Result; - } - #[async_trait] - impl BackendHandler for TestBackendHandler {} - #[async_trait] - impl OpaqueHandler for TestBackendHandler { - async fn login_start( - &self, - request: login::ClientLoginStartRequest - ) -> Result; - async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result; - async fn registration_start( - &self, - request: registration::ClientRegistrationStartRequest - ) -> Result; - async fn registration_finish( - &self, - request: registration::ClientRegistrationFinishRequest - ) -> Result<()>; - } - } - fn make_user_search_request>( filter: LdapFilter, attrs: Vec, @@ -807,44 +749,6 @@ mod tests { setup_bound_handler_with_group(mock, "lldap_admin").await } - fn setup_default_schema(mock: &mut MockTestBackendHandler) { - mock.expect_get_schema().returning(|| { - Ok(Schema { - user_attributes: AttributeList { - attributes: vec![ - AttributeSchema { - name: "avatar".to_owned(), - attribute_type: AttributeType::JpegPhoto, - is_list: false, - is_visible: true, - is_editable: true, - is_hardcoded: true, - }, - AttributeSchema { - name: "first_name".to_owned(), - attribute_type: AttributeType::String, - is_list: false, - is_visible: true, - is_editable: true, - is_hardcoded: true, - }, - AttributeSchema { - name: "last_name".to_owned(), - attribute_type: AttributeType::String, - is_list: false, - is_visible: true, - is_editable: true, - is_hardcoded: true, - }, - ], - }, - group_attributes: AttributeList { - attributes: Vec::new(), - }, - }) - }); - } - #[tokio::test] async fn test_bind() { let mut mock = MockTestBackendHandler::new(); diff --git a/server/src/infra/mod.rs b/server/src/infra/mod.rs index 33a2e58..84173bb 100644 --- a/server/src/infra/mod.rs +++ b/server/src/infra/mod.rs @@ -10,6 +10,10 @@ pub mod ldap_handler; pub mod ldap_server; pub mod logging; pub mod mail; +pub mod schema; pub mod sql_backend_handler; pub mod tcp_backend_handler; pub mod tcp_server; + +#[cfg(test)] +pub mod test_utils; diff --git a/server/src/infra/schema.rs b/server/src/infra/schema.rs new file mode 100644 index 0000000..4096aef --- /dev/null +++ b/server/src/infra/schema.rs @@ -0,0 +1,104 @@ +use crate::domain::{ + handler::{AttributeSchema, Schema}, + types::AttributeType, +}; +use serde::{Deserialize, Serialize}; + +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct PublicSchema(Schema); + +impl PublicSchema { + pub fn get_schema(&self) -> &Schema { + &self.0 + } +} + +impl From for PublicSchema { + fn from(mut schema: Schema) -> Self { + schema.user_attributes.attributes.extend_from_slice(&[ + AttributeSchema { + name: "user_id".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: false, + is_hardcoded: true, + }, + AttributeSchema { + name: "creation_date".to_owned(), + attribute_type: AttributeType::DateTime, + is_list: false, + is_visible: true, + is_editable: false, + is_hardcoded: true, + }, + AttributeSchema { + name: "mail".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + AttributeSchema { + name: "uuid".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: false, + is_hardcoded: true, + }, + AttributeSchema { + name: "display_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + ]); + schema + .user_attributes + .attributes + .sort_by(|a, b| a.name.cmp(&b.name)); + schema.group_attributes.attributes.extend_from_slice(&[ + AttributeSchema { + name: "group_id".to_owned(), + attribute_type: AttributeType::Integer, + is_list: false, + is_visible: true, + is_editable: false, + is_hardcoded: true, + }, + AttributeSchema { + name: "creation_date".to_owned(), + attribute_type: AttributeType::DateTime, + is_list: false, + is_visible: true, + is_editable: false, + is_hardcoded: true, + }, + AttributeSchema { + name: "uuid".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: false, + is_hardcoded: true, + }, + AttributeSchema { + name: "display_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + ]); + schema + .group_attributes + .attributes + .sort_by(|a, b| a.name.cmp(&b.name)); + PublicSchema(schema) + } +} diff --git a/server/src/infra/test_utils.rs b/server/src/infra/test_utils.rs new file mode 100644 index 0000000..f6b44fe --- /dev/null +++ b/server/src/infra/test_utils.rs @@ -0,0 +1,100 @@ +use crate::domain::{error::Result, handler::*, opaque_handler::*, types::*}; + +use async_trait::async_trait; +use std::collections::HashSet; + +mockall::mock! { + pub TestBackendHandler{} + impl Clone for TestBackendHandler { + fn clone(&self) -> Self; + } + #[async_trait] + impl LoginHandler for TestBackendHandler { + async fn bind(&self, request: BindRequest) -> Result<()>; + } + #[async_trait] + impl GroupListerBackendHandler for TestBackendHandler { + async fn list_groups(&self, filters: Option) -> Result>; + } + #[async_trait] + impl GroupBackendHandler for TestBackendHandler { + async fn get_group_details(&self, group_id: GroupId) -> Result; + async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>; + async fn create_group(&self, group_name: &str) -> Result; + async fn delete_group(&self, group_id: GroupId) -> Result<()>; + } + #[async_trait] + impl UserListerBackendHandler for TestBackendHandler { + async fn list_users(&self, filters: Option, get_groups: bool) -> Result>; + } + #[async_trait] + impl UserBackendHandler for TestBackendHandler { + async fn get_user_details(&self, user_id: &UserId) -> Result; + async fn create_user(&self, request: CreateUserRequest) -> Result<()>; + async fn update_user(&self, request: UpdateUserRequest) -> Result<()>; + async fn delete_user(&self, user_id: &UserId) -> Result<()>; + async fn get_user_groups(&self, user_id: &UserId) -> Result>; + async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>; + async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>; + } + #[async_trait] + impl SchemaBackendHandler for TestBackendHandler { + async fn get_schema(&self) -> Result; + } + #[async_trait] + impl BackendHandler for TestBackendHandler {} + #[async_trait] + impl OpaqueHandler for TestBackendHandler { + async fn login_start( + &self, + request: login::ClientLoginStartRequest + ) -> Result; + async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result; + async fn registration_start( + &self, + request: registration::ClientRegistrationStartRequest + ) -> Result; + async fn registration_finish( + &self, + request: registration::ClientRegistrationFinishRequest + ) -> Result<()>; + } +} + +pub fn setup_default_schema(mock: &mut MockTestBackendHandler) { + mock.expect_get_schema().returning(|| { + Ok(Schema { + user_attributes: AttributeList { + attributes: vec![ + AttributeSchema { + name: "avatar".to_owned(), + attribute_type: AttributeType::JpegPhoto, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + AttributeSchema { + name: "first_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + AttributeSchema { + name: "last_name".to_owned(), + attribute_type: AttributeType::String, + is_list: false, + is_visible: true, + is_editable: true, + is_hardcoded: true, + }, + ], + }, + group_attributes: AttributeList { + attributes: Vec::new(), + }, + }) + }); +}