mas_handlers/admin/v1/users/
unlock.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 std::sync::Arc;
8
9use aide::{OperationIo, transform::TransformOperation};
10use axum::{Json, extract::State, response::IntoResponse};
11use hyper::StatusCode;
12use mas_matrix::HomeserverConnection;
13use ulid::Ulid;
14
15use crate::{
16    admin::{
17        call_context::CallContext,
18        model::{Resource, User},
19        params::UlidPathParam,
20        response::{ErrorResponse, SingleResponse},
21    },
22    impl_from_error_for_route,
23};
24
25#[derive(Debug, thiserror::Error, OperationIo)]
26#[aide(output_with = "Json<ErrorResponse>")]
27pub enum RouteError {
28    #[error(transparent)]
29    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
30
31    #[error(transparent)]
32    Homeserver(anyhow::Error),
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 status = match self {
44            Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
45            Self::NotFound(_) => StatusCode::NOT_FOUND,
46        };
47        (status, Json(error)).into_response()
48    }
49}
50
51pub fn doc(operation: TransformOperation) -> TransformOperation {
52    operation
53        .id("unlockUser")
54        .summary("Unlock a user")
55        .tag("user")
56        .response_with::<200, Json<SingleResponse<User>>, _>(|t| {
57            // In the samples, the third user is the one locked
58            let [sample, ..] = User::samples();
59            let id = sample.id();
60            let response = SingleResponse::new(sample, format!("/api/admin/v1/users/{id}/unlock"));
61            t.description("User was unlocked").example(response)
62        })
63        .response_with::<404, RouteError, _>(|t| {
64            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
65            t.description("User ID not found").example(response)
66        })
67}
68
69#[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all, err)]
70pub async fn handler(
71    CallContext { mut repo, .. }: CallContext,
72    State(homeserver): State<Arc<dyn HomeserverConnection>>,
73    id: UlidPathParam,
74) -> Result<Json<SingleResponse<User>>, RouteError> {
75    let id = *id;
76    let user = repo
77        .user()
78        .lookup(id)
79        .await?
80        .ok_or(RouteError::NotFound(id))?;
81
82    // Call the homeserver synchronously to unlock the user
83    let mxid = homeserver.mxid(&user.username);
84    homeserver
85        .reactivate_user(&mxid)
86        .await
87        .map_err(RouteError::Homeserver)?;
88
89    // Now unlock the user in our database
90    let user = repo.user().unlock(user).await?;
91
92    repo.save().await?;
93
94    Ok(Json(SingleResponse::new(
95        User::from(user),
96        format!("/api/admin/v1/users/{id}/unlock"),
97    )))
98}
99
100#[cfg(test)]
101mod tests {
102    use hyper::{Request, StatusCode};
103    use mas_matrix::{HomeserverConnection, ProvisionRequest};
104    use mas_storage::{RepositoryAccess, user::UserRepository};
105    use sqlx::PgPool;
106
107    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
108
109    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
110    async fn test_unlock_user(pool: PgPool) {
111        setup();
112        let mut state = TestState::from_pool(pool).await.unwrap();
113        let token = state.token_with_scope("urn:mas:admin").await;
114
115        let mut repo = state.repository().await.unwrap();
116        let user = repo
117            .user()
118            .add(&mut state.rng(), &state.clock, "alice".to_owned())
119            .await
120            .unwrap();
121        let user = repo.user().lock(&state.clock, user).await.unwrap();
122        repo.save().await.unwrap();
123
124        // Also provision the user on the homeserver, because this endpoint will try to
125        // reactivate it
126        let mxid = state.homeserver_connection.mxid(&user.username);
127        state
128            .homeserver_connection
129            .provision_user(&ProvisionRequest::new(&mxid, &user.sub))
130            .await
131            .unwrap();
132
133        let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
134            .bearer(&token)
135            .empty();
136        let response = state.request(request).await;
137        response.assert_status(StatusCode::OK);
138        let body: serde_json::Value = response.json();
139
140        assert_eq!(
141            body["data"]["attributes"]["locked_at"],
142            serde_json::json!(null)
143        );
144    }
145
146    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
147    async fn test_unlock_deactivated_user(pool: PgPool) {
148        setup();
149        let mut state = TestState::from_pool(pool).await.unwrap();
150        let token = state.token_with_scope("urn:mas:admin").await;
151
152        let mut repo = state.repository().await.unwrap();
153        let user = repo
154            .user()
155            .add(&mut state.rng(), &state.clock, "alice".to_owned())
156            .await
157            .unwrap();
158        let user = repo.user().lock(&state.clock, user).await.unwrap();
159        repo.save().await.unwrap();
160
161        // Provision the user on the homeserver
162        let mxid = state.homeserver_connection.mxid(&user.username);
163        state
164            .homeserver_connection
165            .provision_user(&ProvisionRequest::new(&mxid, &user.sub))
166            .await
167            .unwrap();
168        // but then deactivate it
169        state
170            .homeserver_connection
171            .delete_user(&mxid, true)
172            .await
173            .unwrap();
174
175        // The user should be deactivated on the homeserver
176        let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
177        assert!(mx_user.deactivated);
178
179        let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
180            .bearer(&token)
181            .empty();
182        let response = state.request(request).await;
183        response.assert_status(StatusCode::OK);
184        let body: serde_json::Value = response.json();
185
186        assert_eq!(
187            body["data"]["attributes"]["locked_at"],
188            serde_json::json!(null)
189        );
190        // The user should be reactivated on the homeserver
191        let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
192        assert!(!mx_user.deactivated);
193    }
194
195    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
196    async fn test_lock_unknown_user(pool: PgPool) {
197        setup();
198        let mut state = TestState::from_pool(pool).await.unwrap();
199        let token = state.token_with_scope("urn:mas:admin").await;
200
201        let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/unlock")
202            .bearer(&token)
203            .empty();
204        let response = state.request(request).await;
205        response.assert_status(StatusCode::NOT_FOUND);
206        let body: serde_json::Value = response.json();
207        assert_eq!(
208            body["errors"][0]["title"],
209            "User ID 01040G2081040G2081040G2081 not found"
210        );
211    }
212}