Skip to content

Commit d4324c2

Browse files
authored
Add support for update user endpoint. (#246)
* WIP Add support for update user endpoint. * Add authorization for applying update to a user * update time crate due to infamous rust release compat issue * update required permissions for updatin gusers * remove old comment * add tests for changing groups * fix lints; add test
1 parent e01445c commit d4324c2

11 files changed

Lines changed: 537 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2+
import type { GroupId } from "./GroupId";
3+
import type { UserRole } from "./UserRole";
4+
5+
export interface UpdateUserData { email: string | null, groupId: GroupId | null, role: UserRole | null, }

crates/api-common/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ pub struct User {
4848
pub services_hosts: Option<Vec<ServiceHost>>,
4949
}
5050

51+
#[derive(Serialize, Deserialize, TS, Clone)]
52+
#[serde(rename_all = "camelCase")]
53+
#[ts(export)]
54+
pub struct UpdateUserData {
55+
pub email: Option<String>,
56+
pub group_id: Option<GroupId>,
57+
pub role: Option<UserRole>,
58+
}
59+
5160
#[derive(Serialize, Deserialize, Debug, TS)]
5261
#[serde(rename_all = "camelCase")]
5362
#[ts(export)]

crates/api/src/common.rs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1 @@
1-
pub use netsblox_api_common::{
2-
oauth, AppId, AuthorizedServiceHost, BannedAccount, ClientConfig, ClientId, ClientInfo,
3-
ClientState, ClientStateData, CollaborationInvite, CreateLibraryData, CreateMagicLinkData,
4-
CreateProjectData, Credentials, ExternalClient, ExternalClientState, Group, GroupId,
5-
InvitationId, InvitationState, LibraryMetadata, LinkedAccount, LoginRequest, NewUser, Project,
6-
ProjectId, PublishState, RoleData, RoleId, RoomState, SaveState, ServiceHost, ServiceHostScope,
7-
ServiceSettings, UpdateProjectData, UpdateRoleData, UserRole,
8-
};
9-
pub use netsblox_api_common::{
10-
FriendInvite, FriendLinkState, InvitationResponse, ProjectMetadata, User,
11-
};
1+
pub use netsblox_api_common::*;

crates/api/src/lib.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pub mod error;
44
use crate::common::*;
55
use futures_util::SinkExt;
66
use netsblox_api_common::{
7-
CreateGroupData, CreateMagicLinkData, ServiceHostScope, UpdateGroupData,
7+
CreateGroupData, CreateMagicLinkData, ServiceHostScope, UpdateGroupData, UpdateUserData,
88
};
99
use reqwest::{self, Method, RequestBuilder, Response};
1010
use serde::{Deserialize, Serialize};
@@ -184,6 +184,23 @@ impl Client {
184184
Ok(response.json::<User>().await.unwrap())
185185
}
186186

187+
pub async fn update_user(
188+
&self,
189+
username: &str,
190+
update: &UpdateUserData,
191+
) -> Result<User, error::Error> {
192+
let path = format!("/users/{}", username);
193+
let response = self
194+
.request(Method::PATCH, &path)
195+
.json(&update)
196+
.send()
197+
.await
198+
.map_err(error::Error::RequestError)?;
199+
200+
let response = check_response(response).await?;
201+
Ok(response.json::<User>().await.unwrap())
202+
}
203+
187204
pub async fn set_password(&self, username: &str, password: &str) -> Result<(), error::Error> {
188205
let path = format!("/users/{}/password", username);
189206
let response = self

crates/cli/src/main.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,38 @@ use clap::{Parser, Subcommand};
99
use futures_util::StreamExt;
1010
use inquire::{Confirm, Password, PasswordDisplayMode};
1111
use netsblox_api::common::{
12-
oauth, ClientId, CreateMagicLinkData, CreateProjectData, Credentials, FriendLinkState,
12+
oauth, ClientId, CreateMagicLinkData, CreateProjectData, Credentials, FriendLinkState, GroupId,
1313
InvitationState, LinkedAccount, ProjectId, PublishState, RoleData, SaveState, ServiceHost,
14-
ServiceHostScope, UserRole,
14+
ServiceHostScope, UpdateUserData, UserRole,
1515
};
1616
use netsblox_api::{self, serde_json, Client};
1717
use std::path::Path;
1818
use xmlparser::{Token, Tokenizer};
1919

20+
#[derive(Parser, Debug)]
21+
#[group(required = true, multiple = true)]
22+
struct UserUpdateOpt {
23+
/// Set the user's email
24+
#[clap(long, group = "update_data")]
25+
email: Option<String>,
26+
/// Set the user role (eg, admin, moderator)
27+
#[clap(long, group = "update_data")]
28+
role: Option<UserRole>,
29+
/// Add the user as a member of a given group
30+
#[clap(long, group = "update_data")]
31+
group_id: Option<GroupId>,
32+
}
33+
34+
impl From<&UserUpdateOpt> for UpdateUserData {
35+
fn from(opt: &UserUpdateOpt) -> UpdateUserData {
36+
UpdateUserData {
37+
email: opt.email.clone(),
38+
group_id: opt.group_id.clone(),
39+
role: opt.role.clone(),
40+
}
41+
}
42+
}
43+
2044
/// Manage & moderate user accounts
2145
#[derive(Subcommand, Debug)]
2246
enum Users {
@@ -50,6 +74,14 @@ enum Users {
5074
#[clap(short, long)]
5175
user: Option<String>,
5276
},
77+
/// Update the current user
78+
Update {
79+
#[command(flatten)]
80+
data: UserUpdateOpt,
81+
/// Perform this action on behalf of this user
82+
#[clap(short, long)]
83+
user: Option<String>,
84+
},
5385
/// Change the current user's password
5486
SetPassword {
5587
password: String,
@@ -800,6 +832,10 @@ async fn do_command(mut cfg: Config, args: Cli) -> Result<(), error::Error> {
800832
)
801833
.await?;
802834
}
835+
Users::Update { data, user } => {
836+
let username = user.clone().unwrap_or_else(|| get_current_user(cfg.host()));
837+
client.update_user(&username, &data.into()).await?;
838+
}
803839
Users::SetPassword { password, user } => {
804840
let username = user.clone().unwrap_or_else(|| get_current_user(cfg.host()));
805841
client.set_password(&username, password).await?;

crates/cloud/src/auth/users.rs

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use actix_session::{Session, SessionExt};
44
use actix_web::HttpRequest;
55
use futures::TryStreamExt;
66
use mongodb::bson::doc;
7-
use netsblox_cloud_common::api::{self, ClientId, UserRole};
7+
use netsblox_cloud_common::api::{self, ClientId, UpdateUserData, UserRole};
88

99
use crate::{
1010
app_data::AppData,
@@ -19,42 +19,63 @@ pub(crate) struct CreateUser {
1919
_private: (),
2020
}
2121

22+
/// Authorization to view a given user
2223
#[derive(Debug)]
2324
pub(crate) struct ViewUser {
2425
pub(crate) username: String,
2526
_private: (),
2627
}
2728

29+
/// Authorization to list all users
2830
pub(crate) struct ListUsers {
2931
_private: (),
3032
}
3133

34+
/// Authorization to edit the user with the given username
3235
pub(crate) struct EditUser {
3336
pub(crate) username: String,
3437
_private: (),
3538
}
3639

37-
pub(crate) struct SetPassword {
38-
pub(crate) username: String,
39-
_private: (),
40+
#[cfg(test)]
41+
impl EditUser {
42+
pub(crate) fn test(username: String) -> Self {
43+
Self {
44+
username,
45+
_private: (),
46+
}
47+
}
4048
}
4149

42-
pub(crate) struct BanUser {
50+
/// Authorization to apply the given updates to the specified user
51+
pub(crate) struct UpdateUser {
4352
pub(crate) username: String,
53+
pub(crate) update: UpdateUserData,
4454
_private: (),
4555
}
4656

47-
// TODO: make a macro for making it when testing?
4857
#[cfg(test)]
49-
impl EditUser {
50-
pub(crate) fn test(username: String) -> Self {
58+
impl UpdateUser {
59+
pub(crate) fn test(username: String, update: UpdateUserData) -> Self {
5160
Self {
5261
username,
62+
update,
5363
_private: (),
5464
}
5565
}
5666
}
5767

68+
pub(crate) struct SetPassword {
69+
pub(crate) username: String,
70+
_private: (),
71+
}
72+
73+
pub(crate) struct BanUser {
74+
pub(crate) username: String,
75+
_private: (),
76+
}
77+
78+
// TODO: make a macro for making it when testing?
5879
#[cfg(test)]
5980
impl BanUser {
6081
pub(crate) fn test(username: String) -> Self {
@@ -99,20 +120,40 @@ pub(crate) async fn try_create_user(
99120
}
100121

101122
let new_user_role = data.role.unwrap_or(UserRole::User);
102-
let is_privileged = !matches!(new_user_role, UserRole::User);
123+
try_assign_role(app, req, &new_user_role)
124+
.await
125+
.map(|_| CreateUser { data, _private: () })
126+
}
127+
128+
/// Permissions for assigning a given role. Used as a helper method for related functions.
129+
struct AssignRole {
130+
/// The role that the permissions are assigned for.
131+
_role: UserRole,
132+
_private: (),
133+
}
134+
135+
async fn try_assign_role(
136+
app: &AppData,
137+
req: &HttpRequest,
138+
role: &UserRole,
139+
) -> Result<AssignRole, UserError> {
140+
let is_privileged = !matches!(role, UserRole::User);
103141

104142
let is_authorized = if is_privileged {
105143
// only moderators, admins can make privileged users (up to their role)
106144
let username = utils::get_username(req).ok_or(UserError::LoginRequiredError)?;
107145
let req_role = get_user_role(app, &username).await?;
108-
dbg!(&req_role, &new_user_role);
109-
req_role >= UserRole::Moderator && req_role >= new_user_role
146+
dbg!(&req_role, &role);
147+
req_role >= UserRole::Moderator && req_role >= *role
110148
} else {
111149
true
112150
};
113151

114152
if is_authorized {
115-
Ok(CreateUser { data, _private: () })
153+
Ok(AssignRole {
154+
_role: role.to_owned(),
155+
_private: (),
156+
})
116157
} else {
117158
Err(UserError::PermissionsError)
118159
}
@@ -211,6 +252,32 @@ pub(crate) async fn try_edit_user(
211252
}
212253
}
213254

255+
/// Try to get privileges to apply the given updates to the specified user.
256+
pub(crate) async fn try_update_user(
257+
app: &AppData,
258+
req: &HttpRequest,
259+
username: &str,
260+
update: UpdateUserData,
261+
) -> Result<UpdateUser, UserError> {
262+
// If setting the group_id, we must be able to edit the group
263+
if let Some(group_id) = update.group_id.as_ref() {
264+
auth::try_edit_group(app, req, group_id).await?;
265+
}
266+
267+
// If setting the user role, we must be able to assign those roles
268+
if let Some(role) = update.role.as_ref() {
269+
try_assign_role(app, req, role).await?;
270+
}
271+
272+
try_edit_user(app, req, None, username)
273+
.await
274+
.map(|eu| UpdateUser {
275+
username: eu.username.to_owned(),
276+
update,
277+
_private: (),
278+
})
279+
}
280+
214281
pub(crate) async fn try_set_password(
215282
app: &AppData,
216283
req: &HttpRequest,

crates/cloud/src/errors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub enum UserError {
3535
ProjectUnavailableError,
3636
#[display(fmt = "Must specify project either using url or xml")]
3737
MissingUrlOrXmlError,
38+
#[display(fmt = "Must specify email, role, or groupId to update.")]
39+
UserUpdateFieldRequiredError,
3840
#[display(fmt = "Password reset link already sent. Only 1 can be sent per hour.")]
3941
PasswordResetLinkSentError,
4042
#[display(fmt = "Magic link already sent. Only 1 can be sent per hour.")]
@@ -221,6 +223,7 @@ impl error::ResponseError for UserError {
221223
| Self::OAuthFlowError(..)
222224
| Self::ProjectUnavailableError
223225
| Self::MissingUrlOrXmlError
226+
| Self::UserUpdateFieldRequiredError
224227
| Self::ProjectNotActiveError => StatusCode::BAD_REQUEST,
225228
Self::InviteAlreadyExistsError => StatusCode::CONFLICT,
226229
}

0 commit comments

Comments
 (0)