server: Add graphql support for setting attributes

This commit is contained in:
Valentin Tolmer 2023-10-22 13:07:47 +02:00 committed by nitnelave
parent 9e88bfe6b4
commit c6ecf8d58a
9 changed files with 244 additions and 38 deletions

View file

@ -90,6 +90,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
firstName: to_option(model.first_name),
lastName: to_option(model.last_name),
avatar: None,
attributes: None,
},
};
self.common.call_graphql::<CreateUser, _>(

View file

@ -391,6 +391,8 @@ impl UserDetailsForm {
firstName: None,
lastName: None,
avatar: None,
removeAttributes: None,
insertAttributes: None,
};
let default_user_input = user_input.clone();
let model = self.form.model();

View file

@ -18,7 +18,7 @@ js = []
rust-argon2 = "0.8"
curve25519-dalek = "3"
digest = "0.9"
generic-array = "*"
generic-array = "0.14"
rand = "0.8"
serde = "*"
sha2 = "0.9"

View file

@ -194,6 +194,7 @@ impl TryFrom<ResultEntry> for User {
first_name,
last_name,
avatar: avatar.map(base64::encode),
attributes: None,
},
password,
entry.dn,

46
schema.graphql generated
View file

@ -6,6 +6,7 @@ type AttributeValue {
type Mutation {
createUser(user: CreateUserInput!): User!
createGroup(name: String!): Group!
createGroupWithDetails(request: CreateGroupInput!): Group!
updateUser(user: UpdateUserInput!): Success!
updateGroup(group: UpdateGroupInput!): Success!
addUserToGroup(userId: String!, groupId: Int!): Success!
@ -61,7 +62,8 @@ input CreateUserInput {
displayName: String
firstName: String
lastName: String
avatar: String
"Base64 encoded JpegPhoto." avatar: String
"User-defined attributes." attributes: [AttributeValueInput!]
}
type AttributeSchema {
@ -80,7 +82,15 @@ input UpdateUserInput {
displayName: String
firstName: String
lastName: String
avatar: String
"Base64 encoded JpegPhoto." avatar: String
"""
Attribute names to remove.
They are processed before insertions.
""" removeAttributes: [String!]
"""
Inserts or updates the given attributes.
For lists, the entire list must be provided.
""" insertAttributes: [AttributeValueInput!]
}
input EqualityConstraint {
@ -95,8 +105,36 @@ type Schema {
"The fields that can be updated for a group."
input UpdateGroupInput {
id: Int!
displayName: String
"The group ID." id: Int!
"The new display name." displayName: String
"""
Attribute names to remove.
They are processed before insertions.
""" removeAttributes: [String!]
"""
Inserts or updates the given attributes.
For lists, the entire list must be provided.
""" insertAttributes: [AttributeValueInput!]
}
input AttributeValueInput {
"""
The name of the attribute. It must be present in the schema, and the type informs how
to interpret the values.
""" name: String!
"""
The values of the attribute.
If the attribute is not a list, the vector must contain exactly one element.
Integers (signed 64 bits) are represented as strings.
Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
""" value: [String!]!
}
"The details required to create a group."
input CreateGroupInput {
displayName: String!
"User-defined attributes." attributes: [AttributeValueInput!]
}
type User {

View file

@ -205,13 +205,11 @@ impl TryFrom<Vec<u8>> for JpegPhoto {
}
}
impl TryFrom<String> for JpegPhoto {
impl TryFrom<&str> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(string: String) -> anyhow::Result<Self> {
fn try_from(string: &str) -> anyhow::Result<Self> {
// The String format is in base64.
<Self as TryFrom<_>>::try_from(
base64::engine::general_purpose::STANDARD.decode(string.as_str())?,
)
<Self as TryFrom<_>>::try_from(base64::engine::general_purpose::STANDARD.decode(string)?)
}
}

View file

@ -11,6 +11,7 @@ use crate::domain::{
ReadSchemaBackendHandler, Schema, SchemaBackendHandler, UpdateGroupRequest,
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
},
schema::PublicSchema,
types::{Group, GroupDetails, GroupId, User, UserAndGroups, UserId},
};
@ -71,9 +72,10 @@ impl ValidationResults {
}
#[async_trait]
pub trait UserReadableBackendHandler {
pub trait UserReadableBackendHandler: ReadSchemaBackendHandler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
async fn get_schema(&self) -> Result<PublicSchema>;
}
#[async_trait]
@ -120,6 +122,11 @@ impl<Handler: BackendHandler> UserReadableBackendHandler for Handler {
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>> {
<Handler as UserBackendHandler>::get_user_groups(self, user_id).await
}
async fn get_schema(&self) -> Result<PublicSchema> {
Ok(PublicSchema::from(
<Handler as ReadSchemaBackendHandler>::get_schema(self).await?,
))
}
}
#[async_trait]

View file

@ -1,10 +1,13 @@
use crate::{
domain::{
handler::{
BackendHandler, CreateAttributeRequest, CreateGroupRequest, CreateUserRequest,
UpdateGroupRequest, UpdateUserRequest,
AttributeList, BackendHandler, CreateAttributeRequest, CreateGroupRequest,
CreateUserRequest, UpdateGroupRequest, UpdateUserRequest,
},
types::{
AttributeType, AttributeValue as DomainAttributeValue, GroupId, JpegPhoto, Serialized,
UserId,
},
types::{AttributeType, GroupId, JpegPhoto, UserId},
},
infra::{
access_control::{
@ -14,10 +17,10 @@ use crate::{
graphql::api::{field_error_callback, Context},
},
};
use anyhow::Context as AnyhowContext;
use anyhow::{anyhow, Context as AnyhowContext};
use base64::Engine;
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
use tracing::{debug, debug_span, Instrument};
use tracing::{debug, debug_span, Instrument, Span};
#[derive(PartialEq, Eq, Debug)]
/// The top-level GraphQL mutation type.
@ -33,6 +36,21 @@ impl<Handler: BackendHandler> Mutation<Handler> {
}
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
// This conflicts with the attribute values returned by the user/group queries.
#[graphql(name = "AttributeValueInput")]
struct AttributeValue {
/// The name of the attribute. It must be present in the schema, and the type informs how
/// to interpret the values.
name: String,
/// The values of the attribute.
/// If the attribute is not a list, the vector must contain exactly one element.
/// Integers (signed 64 bits) are represented as strings.
/// Dates are represented as strings in RFC3339 format, e.g. "2019-10-12T07:20:50.52Z".
/// JpegPhotos are represented as base64 encoded strings. They must be valid JPEGs.
value: Vec<String>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a user.
pub struct CreateUserInput {
@ -41,8 +59,18 @@ pub struct CreateUserInput {
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
// Base64 encoded JpegPhoto.
/// Base64 encoded JpegPhoto.
avatar: Option<String>,
/// User-defined attributes.
attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// The details required to create a group.
pub struct CreateGroupInput {
display_name: String,
/// User-defined attributes.
attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@ -53,15 +81,29 @@ pub struct UpdateUserInput {
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
// Base64 encoded JpegPhoto.
/// Base64 encoded JpegPhoto.
avatar: Option<String>,
/// Attribute names to remove.
/// They are processed before insertions.
remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
insert_attributes: Option<Vec<AttributeValue>>,
}
#[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<String>,
/// Attribute names to remove.
/// They are processed before insertions.
remove_attributes: Option<Vec<String>>,
/// Inserts or updates the given attributes.
/// For lists, the entire list must be provided.
insert_attributes: Option<Vec<AttributeValue>>,
}
#[derive(PartialEq, Eq, Debug, GraphQLObject)]
@ -97,6 +139,13 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.map(JpegPhoto::try_from)
.transpose()
.context("Provided image is not a valid JPEG")?;
let schema = handler.get_schema().await?;
let attributes = user
.attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr))
.collect::<Result<Vec<_>, _>>()?;
handler
.create_user(CreateUserRequest {
user_id: user_id.clone(),
@ -105,7 +154,7 @@ impl<Handler: BackendHandler> Mutation<Handler> {
first_name: user.first_name,
last_name: user.last_name,
avatar,
..Default::default()
attributes,
})
.instrument(span.clone())
.await?;
@ -124,19 +173,25 @@ impl<Handler: BackendHandler> Mutation<Handler> {
span.in_scope(|| {
debug!(?name);
});
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
let request = CreateGroupRequest {
display_name: name,
..Default::default()
};
let group_id = handler.create_group(request).await?;
Ok(handler
.get_group_details(group_id)
.instrument(span)
.await
.map(Into::into)?)
create_group_with_details(
context,
CreateGroupInput {
display_name: name,
attributes: Some(Vec::new()),
},
span,
)
.await
}
async fn create_group_with_details(
context: &Context<Handler>,
request: CreateGroupInput,
) -> FieldResult<super::query::Group<Handler>> {
let span = debug_span!("[GraphQL mutation] create_group_with_details");
span.in_scope(|| {
debug!(?request);
});
create_group_with_details(context, request, span).await
}
async fn update_user(
@ -159,6 +214,13 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.map(JpegPhoto::try_from)
.transpose()
.context("Provided image is not a valid JPEG")?;
let schema = handler.get_schema().await?;
let insert_attributes = user
.insert_attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&schema.get_schema().user_attributes, attr))
.collect::<Result<Vec<_>, _>>()?;
handler
.update_user(UpdateUserRequest {
user_id,
@ -167,7 +229,8 @@ impl<Handler: BackendHandler> Mutation<Handler> {
first_name: user.first_name,
last_name: user.last_name,
avatar,
..Default::default()
delete_attributes: user.remove_attributes.unwrap_or_default(),
insert_attributes,
})
.instrument(span)
.await?;
@ -185,16 +248,23 @@ impl<Handler: BackendHandler> Mutation<Handler> {
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group update"))?;
if group.id == 1 {
span.in_scope(|| debug!("Cannot change admin group details"));
return Err("Cannot change admin group details".into());
if group.id == 1 && group.display_name.is_some() {
span.in_scope(|| debug!("Cannot change lldap_admin group name"));
return Err("Cannot change lldap_admin group name".into());
}
let schema = handler.get_schema().await?;
let insert_attributes = group
.insert_attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr))
.collect::<Result<Vec<_>, _>>()?;
handler
.update_group(UpdateGroupRequest {
group_id: GroupId(group.id),
display_name: group.display_name,
delete_attributes: Vec::new(),
insert_attributes: Vec::new(),
delete_attributes: group.remove_attributes.unwrap_or_default(),
insert_attributes,
})
.instrument(span)
.await?;
@ -390,3 +460,91 @@ impl<Handler: BackendHandler> Mutation<Handler> {
Ok(Success::new())
}
}
async fn create_group_with_details<Handler: BackendHandler>(
context: &Context<Handler>,
request: CreateGroupInput,
span: Span,
) -> FieldResult<super::query::Group<Handler>> {
let handler = context
.get_admin_handler()
.ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
let schema = handler.get_schema().await?;
let attributes = request
.attributes
.unwrap_or_default()
.into_iter()
.map(|attr| deserialize_attribute(&schema.get_schema().group_attributes, attr))
.collect::<Result<Vec<_>, _>>()?;
let request = CreateGroupRequest {
display_name: request.display_name,
attributes,
};
let group_id = handler.create_group(request).await?;
Ok(handler
.get_group_details(group_id)
.instrument(span)
.await
.map(Into::into)?)
}
fn deserialize_attribute(
attribute_schema: &AttributeList,
attribute: AttributeValue,
) -> FieldResult<DomainAttributeValue> {
let attribute_type = attribute_schema
.get_attribute_type(&attribute.name)
.ok_or_else(|| anyhow!("Attribute {} is not defined in the schema", attribute.name))?;
if !attribute_type.1 && attribute.value.len() != 1 {
return Err(anyhow!(
"Attribute {} is not a list, but multiple values were provided",
attribute.name
)
.into());
}
let parse_int = |value: &String| -> FieldResult<i64> {
Ok(value
.parse::<i64>()
.with_context(|| format!("Invalid integer value {}", value))?)
};
let parse_date = |value: &String| -> FieldResult<chrono::NaiveDateTime> {
Ok(chrono::DateTime::parse_from_rfc3339(value)
.with_context(|| format!("Invalid date value {}", value))?
.naive_utc())
};
let parse_photo = |value: &String| -> FieldResult<JpegPhoto> {
Ok(JpegPhoto::try_from(value.as_str()).context("Provided image is not a valid JPEG")?)
};
let deserialized_values = match attribute_type {
(AttributeType::String, false) => Serialized::from(&attribute.value[0]),
(AttributeType::String, true) => Serialized::from(&attribute.value),
(AttributeType::Integer, false) => Serialized::from(&parse_int(&attribute.value[0])?),
(AttributeType::Integer, true) => Serialized::from(
&attribute
.value
.iter()
.map(parse_int)
.collect::<FieldResult<Vec<_>>>()?,
),
(AttributeType::DateTime, false) => Serialized::from(&parse_date(&attribute.value[0])?),
(AttributeType::DateTime, true) => Serialized::from(
&attribute
.value
.iter()
.map(parse_date)
.collect::<FieldResult<Vec<_>>>()?,
),
(AttributeType::JpegPhoto, false) => Serialized::from(&parse_photo(&attribute.value[0])?),
(AttributeType::JpegPhoto, true) => Serialized::from(
&attribute
.value
.iter()
.map(parse_photo)
.collect::<FieldResult<Vec<_>>>()?,
),
};
Ok(DomainAttributeValue {
name: attribute.name,
value: deserialized_values,
})
}

View file

@ -108,6 +108,7 @@ impl LLDAPFixture {
display_name: None,
first_name: None,
last_name: None,
attributes: None,
},
},
)