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