mas_handlers/admin/v1/users/
deactivate.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use aide::{NoApi, OperationIo, transform::TransformOperation};
8use axum::{Json, response::IntoResponse};
9use hyper::StatusCode;
10use mas_axum_utils::record_error;
11use mas_storage::{
12    BoxRng,
13    queue::{DeactivateUserJob, QueueJobRepositoryExt as _},
14};
15use tracing::info;
16use ulid::Ulid;
17
18use crate::{
19    admin::{
20        call_context::CallContext,
21        model::{Resource, User},
22        params::UlidPathParam,
23        response::{ErrorResponse, SingleResponse},
24    },
25    impl_from_error_for_route,
26};
27
28#[derive(Debug, thiserror::Error, OperationIo)]
29#[aide(output_with = "Json<ErrorResponse>")]
30pub enum RouteError {
31    #[error(transparent)]
32    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
33
34    #[error("User ID {0} not found")]
35    NotFound(Ulid),
36}
37
38impl_from_error_for_route!(mas_storage::RepositoryError);
39
40impl IntoResponse for RouteError {
41    fn into_response(self) -> axum::response::Response {
42        let error = ErrorResponse::from_error(&self);
43        let sentry_event_id = record_error!(self, Self::Internal(_));
44        let status = match self {
45            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
46            Self::NotFound(_) => StatusCode::NOT_FOUND,
47        };
48        (status, sentry_event_id, Json(error)).into_response()
49    }
50}
51
52pub fn doc(operation: TransformOperation) -> TransformOperation {
53    operation
54        .id("deactivateUser")
55        .summary("Deactivate a user")
56        .description("Calling this endpoint will lock and deactivate the user, preventing them from doing any action.
57This invalidates any existing session, and will ask the homeserver to make them leave all rooms.")
58        .tag("user")
59        .response_with::<200, Json<SingleResponse<User>>, _>(|t| {
60            // In the samples, the third user is the one locked
61            let [_alice, _bob, charlie, ..] = User::samples();
62            let id = charlie.id();
63            let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate"));
64            t.description("User was deactivated").example(response)
65        })
66        .response_with::<404, RouteError, _>(|t| {
67            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
68            t.description("User ID not found").example(response)
69        })
70}
71
72#[tracing::instrument(name = "handler.admin.v1.users.deactivate", skip_all)]
73pub async fn handler(
74    CallContext {
75        mut repo, clock, ..
76    }: CallContext,
77    NoApi(mut rng): NoApi<BoxRng>,
78    id: UlidPathParam,
79) -> Result<Json<SingleResponse<User>>, RouteError> {
80    let id = *id;
81    let mut user = repo
82        .user()
83        .lookup(id)
84        .await?
85        .ok_or(RouteError::NotFound(id))?;
86
87    if user.locked_at.is_none() {
88        user = repo.user().lock(&clock, user).await?;
89    }
90
91    info!(%user.id, "Scheduling deactivation of user");
92    repo.queue_job()
93        .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, true))
94        .await?;
95
96    repo.save().await?;
97
98    Ok(Json(SingleResponse::new(
99        User::from(user),
100        format!("/api/admin/v1/users/{id}/deactivate"),
101    )))
102}
103
104#[cfg(test)]
105mod tests {
106    use chrono::Duration;
107    use hyper::{Request, StatusCode};
108    use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
109    use sqlx::{PgPool, types::Json};
110
111    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
112
113    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
114    async fn test_deactivate_user(pool: PgPool) {
115        setup();
116        let mut state = TestState::from_pool(pool.clone()).await.unwrap();
117        let token = state.token_with_scope("urn:mas:admin").await;
118
119        let mut repo = state.repository().await.unwrap();
120        let user = repo
121            .user()
122            .add(&mut state.rng(), &state.clock, "alice".to_owned())
123            .await
124            .unwrap();
125        repo.save().await.unwrap();
126
127        let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
128            .bearer(&token)
129            .empty();
130        let response = state.request(request).await;
131        response.assert_status(StatusCode::OK);
132        let body: serde_json::Value = response.json();
133
134        // The locked_at timestamp should be the same as the current time
135        assert_eq!(
136            body["data"]["attributes"]["locked_at"],
137            serde_json::json!(state.clock.now())
138        );
139
140        // It should have scheduled a deactivation job for the user
141        // XXX: we don't have a good way to look for the deactivation job
142        let job: Json<serde_json::Value> = sqlx::query_scalar(
143            "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
144        )
145        .fetch_one(&pool)
146        .await
147        .expect("Deactivation job to be scheduled");
148        assert_eq!(job["user_id"], serde_json::json!(user.id));
149    }
150
151    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
152    async fn test_deactivate_locked_user(pool: PgPool) {
153        setup();
154        let mut state = TestState::from_pool(pool.clone()).await.unwrap();
155        let token = state.token_with_scope("urn:mas:admin").await;
156
157        let mut repo = state.repository().await.unwrap();
158        let user = repo
159            .user()
160            .add(&mut state.rng(), &state.clock, "alice".to_owned())
161            .await
162            .unwrap();
163        let user = repo.user().lock(&state.clock, user).await.unwrap();
164        repo.save().await.unwrap();
165
166        // Move the clock forward to make sure the locked_at timestamp doesn't change
167        state.clock.advance(Duration::try_minutes(1).unwrap());
168
169        let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
170            .bearer(&token)
171            .empty();
172        let response = state.request(request).await;
173        response.assert_status(StatusCode::OK);
174        let body: serde_json::Value = response.json();
175
176        // The locked_at timestamp should be different from the current time
177        assert_ne!(
178            body["data"]["attributes"]["locked_at"],
179            serde_json::json!(state.clock.now())
180        );
181
182        // It should have scheduled a deactivation job for the user
183        // XXX: we don't have a good way to look for the deactivation job
184        let job: Json<serde_json::Value> = sqlx::query_scalar(
185            "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
186        )
187        .fetch_one(&pool)
188        .await
189        .expect("Deactivation job to be scheduled");
190        assert_eq!(job["user_id"], serde_json::json!(user.id));
191    }
192
193    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
194    async fn test_deactivate_unknown_user(pool: PgPool) {
195        setup();
196        let mut state = TestState::from_pool(pool).await.unwrap();
197        let token = state.token_with_scope("urn:mas:admin").await;
198
199        let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/deactivate")
200            .bearer(&token)
201            .empty();
202        let response = state.request(request).await;
203        response.assert_status(StatusCode::NOT_FOUND);
204        let body: serde_json::Value = response.json();
205        assert_eq!(
206            body["errors"][0]["title"],
207            "User ID 01040G2081040G2081040G2081 not found"
208        );
209    }
210}