From 37d26a4a58cbedc409c84911d33454d58285c99d Mon Sep 17 00:00:00 2001 From: BobAnkh Date: Fri, 9 Aug 2024 17:14:59 +0000 Subject: [PATCH] feat(api): add group management interfaces Resolves: #18 --- netmito/src/api/group.rs | 93 +++++++++++++++++++------ netmito/src/api/mod.rs | 30 +++++---- netmito/src/client.rs | 82 ++++++++++++++++++++-- netmito/src/config/client.rs | 127 +++++++++++++++++++++++++++++++++-- netmito/src/entity/role.rs | 35 ++++++++-- netmito/src/schema.rs | 12 +++- netmito/src/service/group.rs | 111 +++++++++++++++++++++++++++++- 7 files changed, 437 insertions(+), 53 deletions(-) diff --git a/netmito/src/api/group.rs b/netmito/src/api/group.rs index d1565ce..658f318 100644 --- a/netmito/src/api/group.rs +++ b/netmito/src/api/group.rs @@ -1,33 +1,86 @@ -use axum::{extract::State, Extension, Json}; -use sea_orm::DbErr; +use axum::{ + extract::{Path, State}, + middleware, + routing::{post, put}, + Extension, Json, Router, +}; use crate::{ config::InfraPool, error::{ApiError, ApiResult}, schema::*, - service::{self, auth::AuthUser, name_validator}, + service::{ + self, + auth::{user_auth_middleware, AuthUser}, + }, }; +pub fn group_router(st: InfraPool) -> Router { + Router::new() + .route("/", post(create_group)) + .route( + "/:group/users", + put(update_user_group).delete(remove_user_group), + ) + .layer(middleware::from_fn_with_state( + st.clone(), + user_auth_middleware, + )) + .with_state(st) +} + pub async fn create_group( Extension(u): Extension, State(pool): State, Json(req): Json, ) -> ApiResult<()> { - if !name_validator(&req.group_name) { - Err(ApiError::InvalidRequest("Invalid group name".to_string())) - } else { - match service::group::create_group(&pool.db, u.id, req.group_name.clone()).await { - Ok(_) => Ok(()), - Err(e) => match e { - crate::error::Error::ApiError(e) => Err(e), - crate::error::Error::DbError(DbErr::RecordNotInserted) => { - Err(ApiError::AlreadyExists(req.group_name)) - } - _ => { - tracing::error!("{}", e); - Err(ApiError::InternalServerError) - } - }, - } - } + service::group::create_group(&pool.db, u.id, req.group_name.clone()) + .await + .map_err(|e| match e { + crate::error::Error::AuthError(err) => ApiError::AuthError(err), + crate::error::Error::ApiError(e) => e, + _ => { + tracing::error!("{}", e); + ApiError::InternalServerError + } + })?; + Ok(()) +} + +pub async fn update_user_group( + Extension(u): Extension, + State(pool): State, + Path(group): Path, + Json(req): Json, +) -> ApiResult<()> { + service::group::update_user_group_role(u.id, group, req.relations, &pool) + .await + .map_err(|e| match e { + crate::error::Error::AuthError(err) => ApiError::AuthError(err), + crate::error::Error::ApiError(e) => e, + _ => { + tracing::error!("{}", e); + ApiError::InternalServerError + } + })?; + Ok(()) +} + +pub async fn remove_user_group( + Extension(u): Extension, + State(pool): State, + Path(group): Path, + Json(req): Json, +) -> ApiResult<()> { + service::group::remove_user_group_role(u.id, group, req.users, &pool) + .await + .map_err(|e| match e { + crate::error::Error::AuthError(err) => ApiError::AuthError(err), + crate::error::Error::ApiError(e) => e, + _ => { + tracing::error!("{}", e); + ApiError::InternalServerError + } + })?; + Ok(()) } diff --git a/netmito/src/api/mod.rs b/netmito/src/api/mod.rs index bc1d5cd..5bbddac 100644 --- a/netmito/src/api/mod.rs +++ b/netmito/src/api/mod.rs @@ -36,13 +36,14 @@ pub fn router(st: InfraPool) -> Router { .route("/login", post(user::login_user)) .nest("/user", user::user_router(st.clone())) .nest("/admin", admin::admin_router(st.clone())) - .route( - "/group", - post(group::create_group).layer(middleware::from_fn_with_state( - st.clone(), - user_auth_middleware, - )), - ) + .nest("/groups", group::group_router(st.clone())) + // .route( + // "/group", + // post(group::create_group).layer(middleware::from_fn_with_state( + // st.clone(), + // user_auth_middleware, + // )), + // ) .route( "/worker", post(worker::register).layer(middleware::from_fn_with_state( @@ -63,13 +64,14 @@ pub fn router(st: InfraPool) -> Router { .route("/login", post(user::login_user)) .nest("/user", user::user_router(st.clone())) .nest("/admin", admin::admin_router(st.clone())) - .route( - "/group", - post(group::create_group).layer(middleware::from_fn_with_state( - st.clone(), - user_auth_middleware, - )), - ) + .nest("/groups", group::group_router(st.clone())) + // .route( + // "/group", + // post(group::create_group).layer(middleware::from_fn_with_state( + // st.clone(), + // user_auth_middleware, + // )), + // ) .route( "/worker", post(worker::register).layer(middleware::from_fn_with_state( diff --git a/netmito/src/client.rs b/netmito/src/client.rs index 2ae91b2..149ac35 100644 --- a/netmito/src/client.rs +++ b/netmito/src/client.rs @@ -15,8 +15,8 @@ use crate::{ client::{ CancelWorkerArgs, ClientCommand, ClientInteractiveShell, CreateCommands, CreateGroupArgs, CreateUserArgs, GetArtifactArgs, GetAttachmentArgs, GetCommands, - GetTaskArgs, GetWorkerArgs, ManageCommands, ManageTaskCommands, ManageWorkerCommands, - SubmitTaskArgs, UploadAttachmentArgs, + GetTaskArgs, GetWorkerArgs, ManageCommands, ManageGroupCommands, ManageTaskCommands, + ManageWorkerCommands, SubmitTaskArgs, UploadAttachmentArgs, }, ClientConfig, ClientConfigCli, }, @@ -25,10 +25,11 @@ use crate::{ schema::{ AttachmentQueryInfo, AttachmentsQueryReq, ChangeTaskReq, CreateGroupReq, CreateUserReq, ParsedTaskQueryInfo, RedisConnectionInfo, RemoteResource, RemoteResourceDownloadResp, - RemoveGroupWorkerRoleReq, ReplaceWorkerTagsReq, ResourceDownloadInfo, SubmitTaskReq, - SubmitTaskResp, TaskQueryInfo, TaskQueryResp, TasksQueryReq, UpdateGroupWorkerRoleReq, - UpdateTaskLabelsReq, UploadAttachmentReq, UploadAttachmentResp, WorkerQueryInfo, - WorkerQueryResp, WorkersQueryReq, WorkersQueryResp, + RemoveGroupWorkerRoleReq, RemoveUserGroupRoleReq, ReplaceWorkerTagsReq, + ResourceDownloadInfo, SubmitTaskReq, SubmitTaskResp, TaskQueryInfo, TaskQueryResp, + TasksQueryReq, UpdateGroupWorkerRoleReq, UpdateTaskLabelsReq, UpdateUserGroupRoleReq, + UploadAttachmentReq, UploadAttachmentResp, WorkerQueryInfo, WorkerQueryResp, + WorkersQueryReq, WorkersQueryResp, }, service::{ auth::cred::get_user_credential, @@ -499,7 +500,7 @@ impl MitoClient { } pub async fn create_group(&mut self, args: CreateGroupArgs) -> crate::error::Result<()> { - self.url.set_path("group"); + self.url.set_path("groups"); let req = CreateGroupReq { group_name: args.name, }; @@ -955,6 +956,48 @@ impl MitoClient { } } + pub async fn update_user_group_roles( + &mut self, + group: String, + args: UpdateUserGroupRoleReq, + ) -> crate::error::Result<()> { + self.url.set_path(&format!("groups/{}/users", group)); + let resp = self + .http_client + .put(self.url.as_str()) + .json(&args) + .bearer_auth(&self.credential) + .send() + .await + .map_err(map_reqwest_err)?; + if resp.status().is_success() { + Ok(()) + } else { + Err(get_error_from_resp(resp).await.into()) + } + } + + pub async fn remove_user_group_roles( + &mut self, + group: String, + args: RemoveUserGroupRoleReq, + ) -> crate::error::Result<()> { + self.url.set_path(&format!("groups/{}/users", group)); + let resp = self + .http_client + .delete(self.url.as_str()) + .json(&args) + .bearer_auth(&self.credential) + .send() + .await + .map_err(map_reqwest_err)?; + if resp.status().is_success() { + Ok(()) + } else { + Err(get_error_from_resp(resp).await.into()) + } + } + pub async fn quit(self) {} pub async fn handle_command(&mut self, cmd: T) -> bool @@ -1210,6 +1253,31 @@ impl MitoClient { } } } + ManageCommands::Group(args) => { + let group = args.group; + match args.command { + ManageGroupCommands::Update(args) => { + match self.update_user_group_roles(group, args.into()).await { + Ok(_) => { + tracing::info!("User group roles updated successfully"); + } + Err(e) => { + tracing::error!("{}", e); + } + } + } + ManageGroupCommands::Remove(args) => { + match self.remove_user_group_roles(group, args.into()).await { + Ok(_) => { + tracing::info!("User group roles removed successfully"); + } + Err(e) => { + tracing::error!("{}", e); + } + } + } + } + } }, ClientCommand::Quit => { return false; diff --git a/netmito/src/config/client.rs b/netmito/src/config/client.rs index 1bbe1fc..9ef51a2 100644 --- a/netmito/src/config/client.rs +++ b/netmito/src/config/client.rs @@ -16,13 +16,13 @@ use uuid::Uuid; use crate::{ entity::{ content::ArtifactContentType, - role::GroupWorkerRole, + role::{GroupWorkerRole, UserGroupRole}, state::{TaskExecState, TaskState}, }, schema::{ AttachmentsQueryReq, ChangeTaskReq, RemoteResourceDownload, RemoveGroupWorkerRoleReq, - ReplaceWorkerTagsReq, TaskSpec, TasksQueryReq, UpdateGroupWorkerRoleReq, - UpdateTaskLabelsReq, WorkersQueryReq, + RemoveUserGroupRoleReq, ReplaceWorkerTagsReq, TaskSpec, TasksQueryReq, + UpdateGroupWorkerRoleReq, UpdateTaskLabelsReq, UpdateUserGroupRoleReq, WorkersQueryReq, }, }; @@ -143,6 +143,8 @@ pub enum ManageCommands { Worker(ManageWorkerArgs), /// Manage a task Task(ManageTaskArgs), + /// Manage a group + Group(ManageGroupArgs), } #[derive(Serialize, Debug, Deserialize, Args)] @@ -161,7 +163,6 @@ pub struct CreateUserArgs { #[derive(Serialize, Debug, Deserialize, Args)] pub struct CreateGroupArgs { /// The name of the group - #[arg(short = 'n', long)] pub name: String, } @@ -416,7 +417,7 @@ pub struct ReplaceWorkerTagsArgs { #[derive(Serialize, Debug, Deserialize, Args)] pub struct UpdateWorkerGroupArgs { - /// The name of the group to update + /// The name and role of the group on the worker to update #[arg(num_args = 0.., value_parser = parse_key_val::)] pub roles: Vec<(String, GroupWorkerRole)>, } @@ -481,6 +482,36 @@ pub struct ChangeTaskArgs { pub watch: Option<(Uuid, TaskExecState)>, } +#[derive(Serialize, Debug, Deserialize, Args)] +pub struct ManageGroupArgs { + /// The name of the group + pub group: String, + #[command(subcommand)] + pub command: ManageGroupCommands, +} + +#[derive(Subcommand, Serialize, Debug, Deserialize)] +pub enum ManageGroupCommands { + /// Update the roles of users to a group + Update(UpdateUserGroupArgs), + /// Remove the accessibility of users from a group + Remove(RemoveUserGroupArgs), +} + +#[derive(Serialize, Debug, Deserialize, Args)] +pub struct UpdateUserGroupArgs { + /// The username and role of the user to the group + #[arg(num_args = 0.., value_parser = parse_key_val::)] + pub roles: Vec<(String, UserGroupRole)>, +} + +#[derive(Serialize, Debug, Deserialize, Args)] +pub struct RemoveUserGroupArgs { + /// The username of the user + #[arg(num_args = 0..)] + pub users: Vec, +} + impl Default for ClientConfig { fn default() -> Self { Self { @@ -1109,3 +1140,89 @@ impl From<(Uuid, ChangeTaskArgs)> for ClientCommand { Self::Manage(args.into()) } } + +impl From for ManageGroupCommands { + fn from(args: UpdateUserGroupArgs) -> Self { + Self::Update(args) + } +} + +impl From<(String, UpdateUserGroupArgs)> for ManageGroupArgs { + fn from(args: (String, UpdateUserGroupArgs)) -> Self { + Self { + group: args.0, + command: ManageGroupCommands::Update(args.1), + } + } +} + +impl From<(String, UpdateUserGroupArgs)> for ManageCommands { + fn from(args: (String, UpdateUserGroupArgs)) -> Self { + Self::Group(args.into()) + } +} + +impl From<(String, UpdateUserGroupArgs)> for ManageArgs { + fn from(args: (String, UpdateUserGroupArgs)) -> Self { + Self { + command: ManageCommands::Group(args.into()), + } + } +} + +impl From<(String, UpdateUserGroupArgs)> for ClientCommand { + fn from(args: (String, UpdateUserGroupArgs)) -> Self { + Self::Manage(args.into()) + } +} + +impl From for UpdateUserGroupRoleReq { + fn from(args: UpdateUserGroupArgs) -> Self { + Self { + relations: args.roles.into_iter().collect(), + } + } +} + +impl From for ManageGroupCommands { + fn from(args: RemoveUserGroupArgs) -> Self { + Self::Remove(args) + } +} + +impl From<(String, RemoveUserGroupArgs)> for ManageGroupArgs { + fn from(args: (String, RemoveUserGroupArgs)) -> Self { + Self { + group: args.0, + command: ManageGroupCommands::Remove(args.1), + } + } +} + +impl From<(String, RemoveUserGroupArgs)> for ManageCommands { + fn from(args: (String, RemoveUserGroupArgs)) -> Self { + Self::Group(args.into()) + } +} + +impl From<(String, RemoveUserGroupArgs)> for ManageArgs { + fn from(args: (String, RemoveUserGroupArgs)) -> Self { + Self { + command: ManageCommands::Group(args.into()), + } + } +} + +impl From<(String, RemoveUserGroupArgs)> for ClientCommand { + fn from(args: (String, RemoveUserGroupArgs)) -> Self { + Self::Manage(args.into()) + } +} + +impl From for RemoveUserGroupRoleReq { + fn from(args: RemoveUserGroupArgs) -> Self { + Self { + users: args.users.into_iter().collect(), + } + } +} diff --git a/netmito/src/entity/role.rs b/netmito/src/entity/role.rs index e163aac..53c0f09 100644 --- a/netmito/src/entity/role.rs +++ b/netmito/src/entity/role.rs @@ -5,17 +5,44 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; /// The role of a user to a group. -#[derive(EnumIter, DeriveActiveEnum, Clone, Debug, PartialEq, Eq)] +#[derive( + EnumIter, DeriveActiveEnum, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum, +)] #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum UserGroupRole { /// The user can read the group's tasks. + #[serde(alias = "read", alias = "READ")] Read = 0, /// The user can submit tasks to the group and bring up workers for the group. + #[serde(alias = "write", alias = "WRITE")] Write = 1, /// The user can manage the group's membership and settings. + #[serde(alias = "admin", alias = "ADMIN")] Admin = 2, } +impl FromStr for UserGroupRole { + type Err = crate::error::Error; + + fn from_str(s: &str) -> Result { + match s { + "read" | "Read" | "READ" => Ok(Self::Read), + "write" | "Write" | "WRITE" => Ok(Self::Write), + "admin" | "Admin" | "ADMIN" => Ok(Self::Admin), + _ => Err(crate::error::Error::Custom(format!( + "Invalid UserGroupRole: {}", + s + ))), + } + } +} + +impl Display for UserGroupRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + /// The role of a group to a worker. #[derive( EnumIter, DeriveActiveEnum, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum, @@ -23,13 +50,13 @@ pub enum UserGroupRole { #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum GroupWorkerRole { /// Reserved for future use. - #[serde(alias = "read")] + #[serde(alias = "read", alias = "READ")] Read = 0, /// The group can submit tasks to the worker's queue. - #[serde(alias = "write")] + #[serde(alias = "write", alias = "WRITE")] Write = 1, /// The group can manage the worker's ACL and settings. - #[serde(alias = "admin")] + #[serde(alias = "admin", alias = "ADMIN")] Admin = 2, } diff --git a/netmito/src/schema.rs b/netmito/src/schema.rs index 6a8b96a..791452a 100644 --- a/netmito/src/schema.rs +++ b/netmito/src/schema.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::entity::{ content::{ArtifactContentType, AttachmentContentType}, - role::GroupWorkerRole, + role::{GroupWorkerRole, UserGroupRole}, state::{TaskExecState, TaskState, UserState, WorkerState}, }; @@ -345,6 +345,16 @@ pub struct RemoveGroupWorkerRoleReq { pub groups: HashSet, } +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateUserGroupRoleReq { + pub relations: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RemoveUserGroupRoleReq { + pub users: HashSet, +} + #[derive(Debug, Serialize, Deserialize)] pub struct WorkerShutdown { pub op: Option, diff --git a/netmito/src/service/group.rs b/netmito/src/service/group.rs index 1500bd5..6018f23 100644 --- a/netmito/src/service/group.rs +++ b/netmito/src/service/group.rs @@ -1,12 +1,15 @@ -use sea_orm::{prelude::*, Set, TransactionTrait}; +use std::collections::{HashMap, HashSet}; + +use sea_orm::{prelude::*, QuerySelect, Set, TransactionTrait}; use sea_orm_migration::prelude::*; use crate::{ + config::InfraPool, entity::{ groups as Group, role::UserGroupRole, state::GroupState, user_group as UserGroup, users as User, }, - error::{ApiError, Error}, + error::{ApiError, AuthError, Error}, }; pub async fn create_group( @@ -17,6 +20,9 @@ pub async fn create_group( where C: TransactionTrait, { + if !super::name_validator(&group_name) { + return Err(ApiError::InvalidRequest("Invalid group name".to_string()).into()); + } let group = db .transaction::<_, Group::Model, Error>(|txn| { Box::pin(async move { @@ -119,3 +125,104 @@ where }) .await?) } + +pub async fn update_user_group_role( + user_id: i64, + group_name: String, + mut relations: HashMap, + pool: &InfraPool, +) -> crate::error::Result<()> { + if relations.is_empty() { + return Err(ApiError::InvalidRequest("Empty group relations".to_string()).into()); + } + let group = Group::Entity::find() + .filter(Group::Column::GroupName.eq(&group_name)) + .one(&pool.db) + .await? + .ok_or(ApiError::NotFound(format!("Group {}", group_name)))?; + let user_group = UserGroup::Entity::find() + .filter(UserGroup::Column::UserId.eq(user_id)) + .filter(UserGroup::Column::GroupId.eq(group.id)) + .one(&pool.db) + .await? + .ok_or(AuthError::PermissionDenied)?; + if user_group.role != UserGroupRole::Admin { + return Err(AuthError::PermissionDenied.into()); + } + let user_names = relations.keys().cloned().collect::>(); + let user_count = user_names.len(); + let users: Vec<(i64, String)> = User::Entity::find() + .filter(Expr::col(User::Column::Username).eq(PgFunc::any(user_names))) + .select_only() + .column(User::Column::Id) + .column(User::Column::Username) + .into_tuple() + .all(&pool.db) + .await?; + if users.len() != user_count { + return Err(ApiError::InvalidRequest("Some users do not exist".to_string()).into()); + } + let user_group_relations = users.into_iter().filter_map(|(user_id, username)| { + relations + .remove(&username) + .map(|role| UserGroup::ActiveModel { + user_id: Set(user_id), + group_id: Set(group.id), + role: Set(role), + ..Default::default() + }) + }); + UserGroup::Entity::insert_many(user_group_relations) + .on_conflict( + OnConflict::columns([UserGroup::Column::UserId, UserGroup::Column::GroupId]) + .update_column(UserGroup::Column::Role) + .to_owned(), + ) + .exec(&pool.db) + .await?; + Ok(()) +} + +pub async fn remove_user_group_role( + user_id: i64, + group_name: String, + usernames: HashSet, + pool: &InfraPool, +) -> crate::error::Result<()> { + if usernames.is_empty() { + return Err(ApiError::InvalidRequest("Empty group relations".to_string()).into()); + } + let group = Group::Entity::find() + .filter(Group::Column::GroupName.eq(&group_name)) + .one(&pool.db) + .await? + .ok_or(ApiError::NotFound(format!("Group {}", group_name)))?; + let user_group = UserGroup::Entity::find() + .filter(UserGroup::Column::UserId.eq(user_id)) + .filter(UserGroup::Column::GroupId.eq(group.id)) + .one(&pool.db) + .await? + .ok_or(AuthError::PermissionDenied)?; + if user_group.role != UserGroupRole::Admin { + return Err(AuthError::PermissionDenied.into()); + } + let user_names = usernames.into_iter().collect::>(); + let user_count = user_names.len(); + let users: Vec = User::Entity::find() + .filter(Expr::col(User::Column::Username).eq(PgFunc::any(user_names))) + .select_only() + .column(User::Column::Id) + .column(User::Column::Username) + .into_tuple() + .all(&pool.db) + .await?; + if users.len() != user_count { + return Err(ApiError::InvalidRequest("Some users do not exist".to_string()).into()); + } + UserGroup::Entity::delete_many() + .filter(Expr::col(UserGroup::Column::UserId).eq(PgFunc::any(users))) + .filter(Expr::col(UserGroup::Column::GroupId).eq(group.id)) + .exec(&pool.db) + .await?; + Ok(()) +}