Skip to content

Commit a8b8c8e

Browse files
committed
Add admin API endpoint to reactivate user
1 parent e2aad08 commit a8b8c8e

File tree

13 files changed

+409
-9
lines changed

13 files changed

+409
-9
lines changed

crates/cli/src/commands/manage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ impl Options {
542542

543543
warn!(%user.id, "User scheduling user reactivation");
544544
repo.queue_job()
545-
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user))
545+
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user, true))
546546
.await?;
547547

548548
repo.into_inner().commit().await?;

crates/handlers/src/admin/v1/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ where
9494
"/users/{id}/deactivate",
9595
post_with(self::users::deactivate, self::users::deactivate_doc),
9696
)
97+
.api_route(
98+
"/users/{id}/reactivate",
99+
post_with(self::users::reactivate, self::users::reactivate_doc),
100+
)
97101
.api_route(
98102
"/users/{id}/lock",
99103
post_with(self::users::lock, self::users::lock_doc),

crates/handlers/src/admin/v1/users/deactivate.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ mod tests {
137137
body["data"]["attributes"]["locked_at"],
138138
serde_json::json!(state.clock.now())
139139
);
140+
// TODO: have test coverage on deactivated_at timestamp
140141

141142
// Make sure to run the jobs in the queue
142143
state.run_jobs_in_queue().await;
@@ -201,6 +202,11 @@ mod tests {
201202
body["data"]["attributes"]["locked_at"],
202203
serde_json::json!(state.clock.now())
203204
);
205+
assert_ne!(
206+
body["data"]["attributes"]["locked_at"],
207+
serde_json::Value::Null
208+
);
209+
// TODO: have test coverage on deactivated_at timestamp
204210

205211
// Make sure to run the jobs in the queue
206212
state.run_jobs_in_queue().await;

crates/handlers/src/admin/v1/users/lock.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ mod tests {
157157
body["data"]["attributes"]["locked_at"],
158158
serde_json::json!(state.clock.now())
159159
);
160+
assert_ne!(
161+
body["data"]["attributes"]["locked_at"],
162+
serde_json::Value::Null
163+
);
160164
}
161165

162166
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

crates/handlers/src/admin/v1/users/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod deactivate;
1010
mod get;
1111
mod list;
1212
mod lock;
13+
mod reactivate;
1314
mod set_admin;
1415
mod set_password;
1516
mod unlock;
@@ -21,6 +22,7 @@ pub use self::{
2122
get::{doc as get_doc, handler as get},
2223
list::{doc as list_doc, handler as list},
2324
lock::{doc as lock_doc, handler as lock},
25+
reactivate::{doc as reactivate_doc, handler as reactivate},
2426
set_admin::{doc as set_admin_doc, handler as set_admin},
2527
set_password::{doc as set_password_doc, handler as set_password},
2628
unlock::{doc as unlock_doc, handler as unlock},
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
use aide::{NoApi, OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use hyper::StatusCode;
9+
use mas_axum_utils::record_error;
10+
use mas_storage::{
11+
BoxRng,
12+
queue::{QueueJobRepositoryExt as _, ReactivateUserJob},
13+
};
14+
use tracing::info;
15+
use ulid::Ulid;
16+
17+
use crate::{
18+
admin::{
19+
call_context::CallContext,
20+
model::{Resource, User},
21+
params::UlidPathParam,
22+
response::{ErrorResponse, SingleResponse},
23+
},
24+
impl_from_error_for_route,
25+
};
26+
27+
#[derive(Debug, thiserror::Error, OperationIo)]
28+
#[aide(output_with = "Json<ErrorResponse>")]
29+
pub enum RouteError {
30+
#[error(transparent)]
31+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
32+
33+
#[error("User ID {0} not found")]
34+
NotFound(Ulid),
35+
}
36+
37+
impl_from_error_for_route!(mas_storage::RepositoryError);
38+
39+
impl IntoResponse for RouteError {
40+
fn into_response(self) -> axum::response::Response {
41+
let error = ErrorResponse::from_error(&self);
42+
let sentry_event_id = record_error!(self, Self::Internal(_));
43+
let status = match self {
44+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
45+
Self::NotFound(_) => StatusCode::NOT_FOUND,
46+
};
47+
(status, sentry_event_id, Json(error)).into_response()
48+
}
49+
}
50+
51+
pub fn doc(operation: TransformOperation) -> TransformOperation {
52+
operation
53+
.id("reactivateUser")
54+
.summary("Reactivate a user")
55+
.description("Calling this endpoint will reactivate a deactivated user, both locally and on the Matrix homeserver.")
56+
.tag("user")
57+
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
58+
// In the samples, the third user is the one locked
59+
let [sample, ..] = User::samples();
60+
let id = sample.id();
61+
let response = SingleResponse::new(sample, format!("/api/admin/v1/users/{id}/reactivate"));
62+
t.description("User was reactivated").example(response)
63+
})
64+
.response_with::<404, RouteError, _>(|t| {
65+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
66+
t.description("User ID not found").example(response)
67+
})
68+
}
69+
70+
#[tracing::instrument(name = "handler.admin.v1.users.reactivate", skip_all)]
71+
pub async fn handler(
72+
CallContext {
73+
mut repo, clock, ..
74+
}: CallContext,
75+
NoApi(mut rng): NoApi<BoxRng>,
76+
id: UlidPathParam,
77+
) -> Result<Json<SingleResponse<User>>, RouteError> {
78+
let id = *id;
79+
let user = repo
80+
.user()
81+
.lookup(id)
82+
.await?
83+
.ok_or(RouteError::NotFound(id))?;
84+
85+
info!(%user.id, "Scheduling reactivation of user");
86+
repo.queue_job()
87+
.schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user, false))
88+
.await?;
89+
90+
repo.save().await?;
91+
92+
Ok(Json(SingleResponse::new(
93+
User::from(user),
94+
format!("/api/admin/v1/users/{id}/reactivate"),
95+
)))
96+
}
97+
98+
#[cfg(test)]
99+
mod tests {
100+
use hyper::{Request, StatusCode};
101+
use mas_matrix::{HomeserverConnection, ProvisionRequest};
102+
use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
103+
use sqlx::{PgPool, types::Json};
104+
105+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
106+
107+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
108+
async fn test_reactivate_deactivated_user(pool: PgPool) {
109+
setup();
110+
let mut state = TestState::from_pool(pool.clone()).await.unwrap();
111+
let token = state.token_with_scope("urn:mas:admin").await;
112+
113+
let mut repo = state.repository().await.unwrap();
114+
let user = repo
115+
.user()
116+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
117+
.await
118+
.unwrap();
119+
let user = repo.user().lock(&state.clock, user).await.unwrap();
120+
let user = repo.user().deactivate(&state.clock, user).await.unwrap();
121+
repo.save().await.unwrap();
122+
123+
// Provision and immediately deactivate the user on the homeserver,
124+
// because this endpoint will try to reactivate it
125+
let mxid = state.homeserver_connection.mxid(&user.username);
126+
state
127+
.homeserver_connection
128+
.provision_user(&ProvisionRequest::new(&mxid, &user.sub))
129+
.await
130+
.unwrap();
131+
state
132+
.homeserver_connection
133+
.delete_user(&mxid, true)
134+
.await
135+
.unwrap();
136+
137+
// The user should be deactivated on the homeserver
138+
let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
139+
assert!(mx_user.deactivated);
140+
141+
let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id))
142+
.bearer(&token)
143+
.empty();
144+
let response = state.request(request).await;
145+
response.assert_status(StatusCode::OK);
146+
let body: serde_json::Value = response.json();
147+
148+
// The user should remain locked after being reactivated
149+
assert_eq!(
150+
body["data"]["attributes"]["locked_at"],
151+
serde_json::json!(state.clock.now())
152+
);
153+
// TODO: have test coverage on deactivated_at timestamp
154+
155+
// It should have scheduled a reactivation job for the user
156+
// XXX: we don't have a good way to look for the reactivation job
157+
let job: Json<serde_json::Value> = sqlx::query_scalar(
158+
"SELECT payload FROM queue_jobs WHERE queue_name = 'reactivate-user'",
159+
)
160+
.fetch_one(&pool)
161+
.await
162+
.expect("Reactivation job to be scheduled");
163+
assert_eq!(job["user_id"], serde_json::json!(user.id));
164+
assert_eq!(job["unlock"], serde_json::Value::Bool(false));
165+
}
166+
167+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
168+
async fn test_reactivate_active_user(pool: PgPool) {
169+
setup();
170+
let mut state = TestState::from_pool(pool.clone()).await.unwrap();
171+
let token = state.token_with_scope("urn:mas:admin").await;
172+
173+
let mut repo = state.repository().await.unwrap();
174+
let user = repo
175+
.user()
176+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
177+
.await
178+
.unwrap();
179+
repo.save().await.unwrap();
180+
181+
let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id))
182+
.bearer(&token)
183+
.empty();
184+
let response = state.request(request).await;
185+
response.assert_status(StatusCode::OK);
186+
let body: serde_json::Value = response.json();
187+
188+
assert_eq!(
189+
body["data"]["attributes"]["locked_at"],
190+
serde_json::Value::Null
191+
);
192+
// TODO: have test coverage on deactivated_at timestamp
193+
194+
// It should have scheduled a reactivation job for the user
195+
// XXX: we don't have a good way to look for the reactivation job
196+
let job: Json<serde_json::Value> = sqlx::query_scalar(
197+
"SELECT payload FROM queue_jobs WHERE queue_name = 'reactivate-user'",
198+
)
199+
.fetch_one(&pool)
200+
.await
201+
.expect("Reactivation job to be scheduled");
202+
assert_eq!(job["user_id"], serde_json::json!(user.id));
203+
assert_eq!(job["unlock"], serde_json::Value::Bool(false));
204+
}
205+
206+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
207+
async fn test_reactivate_unknown_user(pool: PgPool) {
208+
setup();
209+
let mut state = TestState::from_pool(pool).await.unwrap();
210+
let token = state.token_with_scope("urn:mas:admin").await;
211+
212+
let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/reactivate")
213+
.bearer(&token)
214+
.empty();
215+
let response = state.request(request).await;
216+
response.assert_status(StatusCode::NOT_FOUND);
217+
let body: serde_json::Value = response.json();
218+
assert_eq!(
219+
body["errors"][0]["title"],
220+
"User ID 01040G2081040G2081040G2081 not found"
221+
);
222+
}
223+
}

crates/handlers/src/admin/v1/users/unlock.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ mod tests {
141141

142142
assert_eq!(
143143
body["data"]["attributes"]["locked_at"],
144-
serde_json::json!(null)
144+
serde_json::Value::Null
145145
);
146146
}
147147

@@ -158,6 +158,7 @@ mod tests {
158158
.await
159159
.unwrap();
160160
let user = repo.user().lock(&state.clock, user).await.unwrap();
161+
let user = repo.user().deactivate(&state.clock, user).await.unwrap();
161162
repo.save().await.unwrap();
162163

163164
// Provision the user on the homeserver
@@ -187,8 +188,10 @@ mod tests {
187188

188189
assert_eq!(
189190
body["data"]["attributes"]["locked_at"],
190-
serde_json::json!(null)
191+
serde_json::Value::Null
191192
);
193+
// TODO: have test coverage on deactivated_at timestamp
194+
192195
// The user should be reactivated on the homeserver
193196
let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
194197
assert!(!mx_user.deactivated);

crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/src/user/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,39 @@ impl UserRepository for PgUserRepository<'_> {
384384
Ok(user)
385385
}
386386

387+
#[tracing::instrument(
388+
name = "db.user.reactivate",
389+
skip_all,
390+
fields(
391+
db.query.text,
392+
%user.id,
393+
),
394+
err,
395+
)]
396+
async fn reactivate(&mut self, mut user: User) -> Result<User, Self::Error> {
397+
if user.deactivated_at.is_none() {
398+
return Ok(user);
399+
}
400+
401+
let res = sqlx::query!(
402+
r#"
403+
UPDATE users
404+
SET deactivated_at = NULL
405+
WHERE user_id = $1
406+
"#,
407+
Uuid::from(user.id),
408+
)
409+
.traced()
410+
.execute(&mut *self.conn)
411+
.await?;
412+
413+
DatabaseError::ensure_affected_rows(&res, 1)?;
414+
415+
user.deactivated_at = None;
416+
417+
Ok(user)
418+
}
419+
387420
#[tracing::instrument(
388421
name = "db.user.set_can_request_admin",
389422
skip_all,

0 commit comments

Comments
 (0)