graphql: Add a method to list groups

This commit is contained in:
Valentin Tolmer 2021-09-16 09:26:31 +02:00 committed by nitnelave
parent e4d6b122c5
commit 480f48f820
11 changed files with 147 additions and 57 deletions

10
Cargo.lock generated
View File

@ -1509,6 +1509,15 @@ dependencies = [
"cfg-if 1.0.0", "cfg-if 1.0.0",
] ]
[[package]]
name = "itertools"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.4.8" version = "0.4.8"
@ -1705,6 +1714,7 @@ dependencies = [
"futures-util", "futures-util",
"hmac 0.10.1", "hmac 0.10.1",
"http", "http",
"itertools",
"juniper", "juniper",
"juniper_actix", "juniper_actix",
"jwt", "jwt",

View File

@ -7,7 +7,7 @@ query GetUserDetails($id: String!) {
lastName lastName
creationDate creationDate
groups { groups {
id displayName
} }
} }
} }

View File

@ -219,7 +219,7 @@ impl Component for UserDetails {
html! { html! {
<tr> <tr>
<td><button>{"-"}</button></td> <td><button>{"-"}</button></td>
<td>{&group.id}</td> <td>{&group.display_name}</td>
</tr> </tr>
} }
}; };

View File

@ -11,7 +11,8 @@ type Mutation {
} }
type Group { type Group {
id: String! id: Int!
displayName: String!
"The groups to which this user belongs." "The groups to which this user belongs."
users: [User!]! users: [User!]!
} }
@ -30,6 +31,13 @@ input RequestFilter {
"DateTime" "DateTime"
scalar DateTimeUtc scalar DateTimeUtc
type Query {
apiVersion: String!
user(userId: String!): User!
users(filters: RequestFilter): [User!]!
groups: [Group!]!
}
"The details required to create a user." "The details required to create a user."
input CreateUserInput { input CreateUserInput {
id: String! id: String!
@ -39,12 +47,6 @@ input CreateUserInput {
lastName: String lastName: String
} }
type Query {
apiVersion: String!
user(userId: String!): User!
users(filters: RequestFilter): [User!]!
}
type User { type User {
id: String! id: String!
email: String! email: String!

View File

@ -45,6 +45,7 @@ tracing-subscriber = "*"
rand = { version = "0.8", features = ["small_rng", "getrandom"] } rand = { version = "0.8", features = ["small_rng", "getrandom"] }
juniper_actix = "0.4.0" juniper_actix = "0.4.0"
juniper = "0.15.6" juniper = "0.15.6"
itertools = "0.10.1"
# TODO: update to 0.6 when out. # TODO: update to 0.6 when out.
[dependencies.opaque-ke] [dependencies.opaque-ke]

View File

@ -31,6 +31,7 @@ impl Default for User {
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Group { pub struct Group {
pub id: GroupId,
pub display_name: String, pub display_name: String,
pub users: Vec<String>, pub users: Vec<String>,
} }
@ -74,9 +75,12 @@ pub trait LoginHandler: Clone + Send {
async fn bind(&self, request: BindRequest) -> Result<()>; async fn bind(&self, request: BindRequest) -> Result<()>;
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GroupId(pub i32); pub struct GroupId(pub i32);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GroupIdAndName(pub GroupId, pub String);
#[async_trait] #[async_trait]
pub trait BackendHandler: Clone + Send { pub trait BackendHandler: Clone + Send {
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>; async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
@ -88,7 +92,7 @@ pub trait BackendHandler: Clone + Send {
async fn create_group(&self, group_name: &str) -> Result<GroupId>; async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>; async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>; async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn get_user_groups(&self, user: &str) -> Result<HashSet<String>>; async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
} }
#[cfg(test)] #[cfg(test)]
@ -106,7 +110,7 @@ mockall::mock! {
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>; async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn delete_user(&self, user_id: &str) -> Result<()>; async fn delete_user(&self, user_id: &str) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>; async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn get_user_groups(&self, user: &str) -> Result<HashSet<String>>; async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>; async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>; async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
} }

View File

@ -2,7 +2,6 @@ use super::{error::*, handler::*, sql_tables::*};
use crate::infra::configuration::Configuration; use crate::infra::configuration::Configuration;
use async_trait::async_trait; use async_trait::async_trait;
use futures_util::StreamExt; use futures_util::StreamExt;
use futures_util::TryStreamExt;
use sea_query::{Expr, Iden, Order, Query, SimpleExpr}; use sea_query::{Expr, Iden, Order, Query, SimpleExpr};
use sqlx::Row; use sqlx::Row;
use std::collections::HashSet; use std::collections::HashSet;
@ -76,6 +75,7 @@ impl BackendHandler for SqlBackendHandler {
async fn list_groups(&self) -> Result<Vec<Group>> { async fn list_groups(&self) -> Result<Vec<Group>> {
let query: String = Query::select() let query: String = Query::select()
.column((Groups::Table, Groups::GroupId))
.column(Groups::DisplayName) .column(Groups::DisplayName)
.column(Memberships::UserId) .column(Memberships::UserId)
.from(Groups::Table) .from(Groups::Table)
@ -88,32 +88,33 @@ impl BackendHandler for SqlBackendHandler {
.order_by(Memberships::UserId, Order::Asc) .order_by(Memberships::UserId, Order::Asc)
.to_string(DbQueryBuilder {}); .to_string(DbQueryBuilder {});
let mut results = sqlx::query(&query).fetch(&self.sql_pool); // For group_by.
use itertools::Itertools;
let mut groups = Vec::new(); let mut groups = Vec::new();
// The rows are ordered by group, user, so we need to group them into vectors. // The rows are returned sorted by display_name, equivalent to group_id. We group them by
// this key which gives us one element (`rows`) per group.
for ((group_id, display_name), rows) in &sqlx::query(&query)
.fetch_all(&self.sql_pool)
.await?
.into_iter()
.group_by(|row| {
(
GroupId(row.get::<i32, _>(&*Groups::GroupId.to_string())),
row.get::<String, _>(&*Groups::DisplayName.to_string()),
)
})
{ {
let mut current_group = String::new();
let mut current_users = Vec::new();
while let Some(row) = results.try_next().await? {
let display_name = row.get::<String, _>(&*Groups::DisplayName.to_string());
if display_name != current_group {
if !current_group.is_empty() {
groups.push(Group {
display_name: current_group,
users: current_users,
});
current_users = Vec::new();
}
current_group = display_name.clone();
}
current_users.push(row.get::<String, _>(&*Memberships::UserId.to_string()));
}
groups.push(Group { groups.push(Group {
display_name: current_group, id: group_id,
users: current_users, display_name,
users: rows
.map(|row| row.get::<String, _>(&*Memberships::UserId.to_string()))
// If a group has no users, an empty string is returned because of the left
// join.
.filter(|s| !s.is_empty())
.collect(),
}); });
} }
Ok(groups) Ok(groups)
} }
@ -135,13 +136,14 @@ impl BackendHandler for SqlBackendHandler {
.await?) .await?)
} }
async fn get_user_groups(&self, user: &str) -> Result<HashSet<String>> { async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>> {
if user == self.config.ldap_user_dn { if user == self.config.ldap_user_dn {
let mut groups = HashSet::new(); let mut groups = HashSet::new();
groups.insert("lldap_admin".to_string()); groups.insert(GroupIdAndName(GroupId(1), "lldap_admin".to_string()));
return Ok(groups); return Ok(groups);
} }
let query: String = Query::select() let query: String = Query::select()
.column((Groups::Table, Groups::GroupId))
.column(Groups::DisplayName) .column(Groups::DisplayName)
.from(Groups::Table) .from(Groups::Table)
.inner_join( .inner_join(
@ -154,10 +156,15 @@ impl BackendHandler for SqlBackendHandler {
sqlx::query(&query) sqlx::query(&query)
// Extract the group id from the row. // Extract the group id from the row.
.map(|row: DbRow| row.get::<String, _>(&*Groups::DisplayName.to_string())) .map(|row: DbRow| {
GroupIdAndName(
row.get::<GroupId, _>(&*Groups::GroupId.to_string()),
row.get::<String, _>(&*Groups::DisplayName.to_string()),
)
})
.fetch(&self.sql_pool) .fetch(&self.sql_pool)
// Collect the vector of rows, each potentially an error. // Collect the vector of rows, each potentially an error.
.collect::<Vec<sqlx::Result<String>>>() .collect::<Vec<sqlx::Result<GroupIdAndName>>>()
.await .await
.into_iter() .into_iter()
// Transform it into a single result (the first error if any), and group the group_ids // Transform it into a single result (the first error if any), and group the group_ids
@ -468,6 +475,7 @@ mod tests {
insert_user(&handler, "John", "Pa33w0rd!").await; insert_user(&handler, "John", "Pa33w0rd!").await;
let group_1 = insert_group(&handler, "Best Group").await; let group_1 = insert_group(&handler, "Best Group").await;
let group_2 = insert_group(&handler, "Worst Group").await; let group_2 = insert_group(&handler, "Worst Group").await;
let group_3 = insert_group(&handler, "Empty Group").await;
insert_membership(&handler, group_1, "bob").await; insert_membership(&handler, group_1, "bob").await;
insert_membership(&handler, group_1, "patrick").await; insert_membership(&handler, group_1, "patrick").await;
insert_membership(&handler, group_2, "patrick").await; insert_membership(&handler, group_2, "patrick").await;
@ -476,13 +484,20 @@ mod tests {
handler.list_groups().await.unwrap(), handler.list_groups().await.unwrap(),
vec![ vec![
Group { Group {
id: group_1,
display_name: "Best Group".to_string(), display_name: "Best Group".to_string(),
users: vec!["bob".to_string(), "patrick".to_string()] users: vec!["bob".to_string(), "patrick".to_string()]
}, },
Group { Group {
id: group_3,
display_name: "Empty Group".to_string(),
users: vec![]
},
Group {
id: group_2,
display_name: "Worst Group".to_string(), display_name: "Worst Group".to_string(),
users: vec!["John".to_string(), "patrick".to_string()] users: vec!["John".to_string(), "patrick".to_string()]
} },
] ]
); );
} }
@ -515,10 +530,10 @@ mod tests {
insert_membership(&handler, group_1, "patrick").await; insert_membership(&handler, group_1, "patrick").await;
insert_membership(&handler, group_2, "patrick").await; insert_membership(&handler, group_2, "patrick").await;
let mut bob_groups = HashSet::new(); let mut bob_groups = HashSet::new();
bob_groups.insert("Group1".to_string()); bob_groups.insert(GroupIdAndName(group_1, "Group1".to_string()));
let mut patrick_groups = HashSet::new(); let mut patrick_groups = HashSet::new();
patrick_groups.insert("Group1".to_string()); patrick_groups.insert(GroupIdAndName(group_1, "Group1".to_string()));
patrick_groups.insert("Group2".to_string()); patrick_groups.insert(GroupIdAndName(group_2, "Group2".to_string()));
assert_eq!(handler.get_user_groups("bob").await.unwrap(), bob_groups); assert_eq!(handler.get_user_groups("bob").await.unwrap(), bob_groups);
assert_eq!( assert_eq!(
handler.get_user_groups("patrick").await.unwrap(), handler.get_user_groups("patrick").await.unwrap(),

View File

@ -12,6 +12,31 @@ impl From<GroupId> for Value {
} }
} }
impl<DB> sqlx::Type<DB> for GroupId
where
DB: sqlx::Database,
i32: sqlx::Type<DB>,
{
fn type_info() -> <DB as sqlx::Database>::TypeInfo {
<i32 as sqlx::Type<DB>>::type_info()
}
fn compatible(ty: &<DB as sqlx::Database>::TypeInfo) -> bool {
<i32 as sqlx::Type<DB>>::compatible(ty)
}
}
impl<'r, DB> sqlx::Decode<'r, DB> for GroupId
where
DB: sqlx::Database,
i32: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::database::HasValueRef<'r>>::ValueRef,
) -> Result<Self, Box<dyn std::error::Error + Sync + Send + 'static>> {
<i32 as sqlx::Decode<'r, DB>>::decode(value).map(GroupId)
}
}
#[derive(Iden)] #[derive(Iden)]
pub enum Users { pub enum Users {
Table, Table,

View File

@ -1,7 +1,7 @@
use crate::{ use crate::{
domain::{ domain::{
error::DomainError, error::DomainError,
handler::{BackendHandler, BindRequest, LoginHandler}, handler::{BackendHandler, BindRequest, GroupIdAndName, LoginHandler},
opaque_handler::OpaqueHandler, opaque_handler::OpaqueHandler,
}, },
infra::{ infra::{
@ -32,12 +32,12 @@ use time::ext::NumericalDuration;
type Token<S> = jwt::Token<jwt::Header, JWTClaims, S>; type Token<S> = jwt::Token<jwt::Header, JWTClaims, S>;
type SignedToken = Token<jwt::token::Signed>; type SignedToken = Token<jwt::token::Signed>;
fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<String>) -> SignedToken { fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupIdAndName>) -> SignedToken {
let claims = JWTClaims { let claims = JWTClaims {
exp: Utc::now() + chrono::Duration::days(1), exp: Utc::now() + chrono::Duration::days(1),
iat: Utc::now(), iat: Utc::now(),
user, user,
groups, groups: groups.into_iter().map(|g| g.1).collect(),
}; };
let header = jwt::Header { let header = jwt::Header {
algorithm: jwt::AlgorithmType::Hs512, algorithm: jwt::AlgorithmType::Hs512,

View File

@ -1,10 +1,11 @@
use crate::domain::handler::BackendHandler; use crate::domain::handler::{BackendHandler, GroupIdAndName};
use juniper::{graphql_object, FieldResult, GraphQLInputObject}; use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::convert::TryInto; use std::convert::TryInto;
type DomainRequestFilter = crate::domain::handler::RequestFilter; type DomainRequestFilter = crate::domain::handler::RequestFilter;
type DomainUser = crate::domain::handler::User; type DomainUser = crate::domain::handler::User;
type DomainGroup = crate::domain::handler::Group;
use super::api::Context; use super::api::Context;
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] #[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@ -113,6 +114,17 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
.await .await
.map(|v| v.into_iter().map(Into::into).collect())?) .map(|v| v.into_iter().map(Into::into).collect())?)
} }
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
if !context.validation_result.is_admin {
return Err("Unauthorized access to group list".into());
}
Ok(context
.handler
.list_groups()
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
} }
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
@ -179,14 +191,19 @@ impl<Handler: BackendHandler> From<DomainUser> for User<Handler> {
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
/// Represents a single group. /// Represents a single group.
pub struct Group<Handler: BackendHandler> { pub struct Group<Handler: BackendHandler> {
group_id: String, group_id: i32,
display_name: String,
members: Option<Vec<String>>,
_phantom: std::marker::PhantomData<Box<Handler>>, _phantom: std::marker::PhantomData<Box<Handler>>,
} }
#[graphql_object(context = Context<Handler>)] #[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler + Sync> Group<Handler> { impl<Handler: BackendHandler + Sync> Group<Handler> {
fn id(&self) -> String { fn id(&self) -> i32 {
self.group_id.clone() self.group_id
}
fn display_name(&self) -> String {
self.display_name.clone()
} }
/// The groups to which this user belongs. /// The groups to which this user belongs.
async fn users(&self, context: &Context<Handler>) -> FieldResult<Vec<User<Handler>>> { async fn users(&self, context: &Context<Handler>) -> FieldResult<Vec<User<Handler>>> {
@ -197,10 +214,23 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
} }
} }
impl<Handler: BackendHandler> From<String> for Group<Handler> { impl<Handler: BackendHandler> From<GroupIdAndName> for Group<Handler> {
fn from(group_id: String) -> Self { fn from(group_id_and_name: GroupIdAndName) -> Self {
Self { Self {
group_id, group_id: group_id_and_name.0 .0,
display_name: group_id_and_name.1,
members: None,
_phantom: std::marker::PhantomData,
}
}
}
impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
fn from(group: DomainGroup) -> Self {
Self {
group_id: group.id.0,
display_name: group.display_name,
members: Some(group.users.into_iter().map(Into::into).collect()),
_phantom: std::marker::PhantomData, _phantom: std::marker::PhantomData,
} }
} }
@ -209,7 +239,10 @@ impl<Handler: BackendHandler> From<String> for Group<Handler> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{domain::handler::MockTestBackendHandler, infra::auth_service::ValidationResults}; use crate::{
domain::handler::{GroupId, GroupIdAndName, MockTestBackendHandler},
infra::auth_service::ValidationResults,
};
use juniper::{ use juniper::{
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType, execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
RootNode, Variables, RootNode, Variables,
@ -250,8 +283,8 @@ mod tests {
..Default::default() ..Default::default()
}) })
}); });
let mut groups = HashSet::<String>::new(); let mut groups = HashSet::new();
groups.insert("Bobbersons".to_string()); groups.insert(GroupIdAndName(GroupId(3), "Bobbersons".to_string()));
mock.expect_get_user_groups() mock.expect_get_user_groups()
.with(eq("bob")) .with(eq("bob"))
.return_once(|_| Ok(groups)); .return_once(|_| Ok(groups));
@ -270,7 +303,7 @@ mod tests {
"user": { "user": {
"id": "bob", "id": "bob",
"email": "bob@bobbers.on", "email": "bob@bobbers.on",
"groups": [{"id": "Bobbersons"}] "groups": [{"id": 3}]
} }
}), }),
vec![] vec![]

View File

@ -29,7 +29,7 @@ mockall::mock! {
async fn list_users(&self, filters: Option<RequestFilter>) -> DomainResult<Vec<User>>; async fn list_users(&self, filters: Option<RequestFilter>) -> DomainResult<Vec<User>>;
async fn list_groups(&self) -> DomainResult<Vec<Group>>; async fn list_groups(&self) -> DomainResult<Vec<Group>>;
async fn get_user_details(&self, user_id: &str) -> DomainResult<User>; async fn get_user_details(&self, user_id: &str) -> DomainResult<User>;
async fn get_user_groups(&self, user: &str) -> DomainResult<HashSet<String>>; async fn get_user_groups(&self, user: &str) -> DomainResult<HashSet<GroupIdAndName>>;
async fn create_user(&self, request: CreateUserRequest) -> DomainResult<()>; async fn create_user(&self, request: CreateUserRequest) -> DomainResult<()>;
async fn update_user(&self, request: UpdateUserRequest) -> DomainResult<()>; async fn update_user(&self, request: UpdateUserRequest) -> DomainResult<()>;
async fn delete_user(&self, user_id: &str) -> DomainResult<()>; async fn delete_user(&self, user_id: &str) -> DomainResult<()>;