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_storage::{
11    BoxRng,
12    queue::{DeactivateUserJob, QueueJobRepositoryExt as _},
13};
14use tracing::info;
15use ulid::Ulid;
16
17use 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>")]
29pub 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
37impl_from_error_for_route!(mas_storage::RepositoryError);
38
39impl IntoResponse for RouteError {
40    fn into_response(self) -> axum::response::Response {
41        let error = ErrorResponse::from_error(&self);
42        let status = match self {
43            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
44            Self::NotFound(_) => StatusCode::NOT_FOUND,
45        };
46        (status, Json(error)).into_response()
47    }
48}
49
50pub fn doc(operation: TransformOperation) -> TransformOperation {
51    operation
52        .id("deactivateUser")
53        .summary("Deactivate a user")
54        .description("Calling this endpoint will lock and deactivate the user, preventing them from doing any action.
55This invalidates any existing session, and will ask the homeserver to make them leave all rooms.")
56        .tag("user")
57        .response_with::<200, Json<SingleResponse<User>>, _>(|t| {
58            // In the samples, the third user is the one locked
59            let [_alice, _bob, charlie, ..] = User::samples();
60            let id = charlie.id();
61            let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate"));
62            t.description("User was deactivated").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.deactivate", skip_all, err)]
71pub 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 mut user = repo
80        .user()
81        .lookup(id)
82        .await?
83        .ok_or(RouteError::NotFound(id))?;
84
85    if user.locked_at.is_none() {
86        user = repo.user().lock(&clock, user).await?;
87    }
88
89    info!("Scheduling deactivation of user {}", user.id);
90    repo.queue_job()
91        .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, true))
92        .await?;
93
94    repo.save().await?;
95
96    Ok(Json(SingleResponse::new(
97        User::from(user),
98        format!("/api/admin/v1/users/{id}/deactivate"),
99    )))
100}
101
102#[cfg(test)]
103mod tests {
104    use chrono::Duration;
105    use hyper::{Request, StatusCode};
106    use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
107    use sqlx::{PgPool, types::Json};
108
109    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
110
111    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
112    async fn test_deactivate_user(pool: PgPool) {
113        setup();
114        let mut state = TestState::from_pool(pool.clone()).await.unwrap();
115        let token = state.token_with_scope("urn:mas:admin").await;
116
117        let mut repo = state.repository().await.unwrap();
118        let user = repo
119            .user()
120            .add(&mut state.rng(), &state.clock, "alice".to_owned())
121            .await
122            .unwrap();
123        repo.save().await.unwrap();
124
125        let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
126            .bearer(&token)
127            .empty();
128        let response = state.request(request).await;
129        response.assert_status(StatusCode::OK);
130        let body: serde_json::Value = response.json();
131
132        // The locked_at timestamp should be the same as the current time
133        assert_eq!(
134            body["data"]["attributes"]["locked_at"],
135            serde_json::json!(state.clock.now())
136        );
137
138        // It should have scheduled a deactivation job for the user
139        // XXX: we don't have a good way to look for the deactivation job
140        let job: Json<serde_json::Value> = sqlx::query_scalar(
141            "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
142        )
143        .fetch_one(&pool)
144        .await
145        .expect("Deactivation job to be scheduled");
146        assert_eq!(job["user_id"], serde_json::json!(user.id));
147    }
148
149    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
150    async fn test_deactivate_locked_user(pool: PgPool) {
151        setup();
152        let mut state = TestState::from_pool(pool.clone()).await.unwrap();
153        let token = state.token_with_scope("urn:mas:admin").await;
154
155        let mut repo = state.repository().await.unwrap();
156        let user = repo
157            .user()
158            .add(&mut state.rng(), &state.clock, "alice".to_owned())
159            .await
160            .unwrap();
161        let user = repo.user().lock(&state.clock, user).await.unwrap();
162        repo.save().await.unwrap();
163
164        // Move the clock forward to make sure the locked_at timestamp doesn't change
165        state.clock.advance(Duration::try_minutes(1).unwrap());
166
167        let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id))
168            .bearer(&token)
169            .empty();
170        let response = state.request(request).await;
171        response.assert_status(StatusCode::OK);
172        let body: serde_json::Value = response.json();
173
174        // The locked_at timestamp should be different from the current time
175        assert_ne!(
176            body["data"]["attributes"]["locked_at"],
177            serde_json::json!(state.clock.now())
178        );
179
180        // It should have scheduled a deactivation job for the user
181        // XXX: we don't have a good way to look for the deactivation job
182        let job: Json<serde_json::Value> = sqlx::query_scalar(
183            "SELECT payload FROM queue_jobs WHERE queue_name = 'deactivate-user'",
184        )
185        .fetch_one(&pool)
186        .await
187        .expect("Deactivation job to be scheduled");
188        assert_eq!(job["user_id"], serde_json::json!(user.id));
189    }
190
191    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
192    async fn test_deactivate_unknown_user(pool: PgPool) {
193        setup();
194        let mut state = TestState::from_pool(pool).await.unwrap();
195        let token = state.token_with_scope("urn:mas:admin").await;
196
197        let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/deactivate")
198            .bearer(&token)
199            .empty();
200        let response = state.request(request).await;
201        response.assert_status(StatusCode::NOT_FOUND);
202        let body: serde_json::Value = response.json();
203        assert_eq!(
204            body["errors"][0]["title"],
205            "User ID 01040G2081040G2081040G2081 not found"
206        );
207    }
208}