mas_handlers/admin/v1/users/
lock.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::{OperationIo, transform::TransformOperation};
8use axum::{Json, response::IntoResponse};
9use hyper::StatusCode;
10use mas_axum_utils::record_error;
11use ulid::Ulid;
12
13use crate::{
14    admin::{
15        call_context::CallContext,
16        model::{Resource, User},
17        params::UlidPathParam,
18        response::{ErrorResponse, SingleResponse},
19    },
20    impl_from_error_for_route,
21};
22
23#[derive(Debug, thiserror::Error, OperationIo)]
24#[aide(output_with = "Json<ErrorResponse>")]
25pub enum RouteError {
26    #[error(transparent)]
27    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
28
29    #[error("User ID {0} not found")]
30    NotFound(Ulid),
31}
32
33impl_from_error_for_route!(mas_storage::RepositoryError);
34
35impl IntoResponse for RouteError {
36    fn into_response(self) -> axum::response::Response {
37        let error = ErrorResponse::from_error(&self);
38        let sentry_event_id = record_error!(self, Self::Internal(_));
39        let status = match self {
40            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
41            Self::NotFound(_) => StatusCode::NOT_FOUND,
42        };
43        (status, sentry_event_id, Json(error)).into_response()
44    }
45}
46
47pub fn doc(operation: TransformOperation) -> TransformOperation {
48    operation
49        .id("lockUser")
50        .summary("Lock a user")
51        .description("Calling this endpoint will lock the user, preventing them from doing any action.
52This DOES NOT invalidate any existing session, meaning that all their existing sessions will work again as soon as they get unlocked.")
53        .tag("user")
54        .response_with::<200, Json<SingleResponse<User>>, _>(|t| {
55            // In the samples, the third user is the one locked
56            let [_alice, _bob, charlie, ..] = User::samples();
57            let id = charlie.id();
58            let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/lock"));
59            t.description("User was locked").example(response)
60        })
61        .response_with::<404, RouteError, _>(|t| {
62            let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
63            t.description("User ID not found").example(response)
64        })
65}
66
67#[tracing::instrument(name = "handler.admin.v1.users.lock", skip_all)]
68pub async fn handler(
69    CallContext {
70        mut repo, clock, ..
71    }: CallContext,
72    id: UlidPathParam,
73) -> Result<Json<SingleResponse<User>>, RouteError> {
74    let id = *id;
75    let mut user = repo
76        .user()
77        .lookup(id)
78        .await?
79        .ok_or(RouteError::NotFound(id))?;
80
81    if user.locked_at.is_none() {
82        user = repo.user().lock(&clock, user).await?;
83    }
84
85    repo.save().await?;
86
87    Ok(Json(SingleResponse::new(
88        User::from(user),
89        format!("/api/admin/v1/users/{id}/lock"),
90    )))
91}
92
93#[cfg(test)]
94mod tests {
95    use chrono::Duration;
96    use hyper::{Request, StatusCode};
97    use mas_storage::{Clock, RepositoryAccess, user::UserRepository};
98    use sqlx::PgPool;
99
100    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
101
102    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
103    async fn test_lock_user(pool: PgPool) {
104        setup();
105        let mut state = TestState::from_pool(pool).await.unwrap();
106        let token = state.token_with_scope("urn:mas:admin").await;
107
108        let mut repo = state.repository().await.unwrap();
109        let user = repo
110            .user()
111            .add(&mut state.rng(), &state.clock, "alice".to_owned())
112            .await
113            .unwrap();
114        repo.save().await.unwrap();
115
116        let request = Request::post(format!("/api/admin/v1/users/{}/lock", user.id))
117            .bearer(&token)
118            .empty();
119        let response = state.request(request).await;
120        response.assert_status(StatusCode::OK);
121        let body: serde_json::Value = response.json();
122
123        // The locked_at timestamp should be the same as the current time
124        assert_eq!(
125            body["data"]["attributes"]["locked_at"],
126            serde_json::json!(state.clock.now())
127        );
128    }
129
130    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
131    async fn test_lock_user_twice(pool: PgPool) {
132        setup();
133        let mut state = TestState::from_pool(pool).await.unwrap();
134        let token = state.token_with_scope("urn:mas:admin").await;
135
136        let mut repo = state.repository().await.unwrap();
137        let user = repo
138            .user()
139            .add(&mut state.rng(), &state.clock, "alice".to_owned())
140            .await
141            .unwrap();
142        let user = repo.user().lock(&state.clock, user).await.unwrap();
143        repo.save().await.unwrap();
144
145        // Move the clock forward to make sure the locked_at timestamp doesn't change
146        state.clock.advance(Duration::try_minutes(1).unwrap());
147
148        let request = Request::post(format!("/api/admin/v1/users/{}/lock", user.id))
149            .bearer(&token)
150            .empty();
151        let response = state.request(request).await;
152        response.assert_status(StatusCode::OK);
153        let body: serde_json::Value = response.json();
154
155        // The locked_at timestamp should be different from the current time
156        assert_ne!(
157            body["data"]["attributes"]["locked_at"],
158            serde_json::json!(state.clock.now())
159        );
160    }
161
162    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
163    async fn test_lock_unknown_user(pool: PgPool) {
164        setup();
165        let mut state = TestState::from_pool(pool).await.unwrap();
166        let token = state.token_with_scope("urn:mas:admin").await;
167
168        let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/lock")
169            .bearer(&token)
170            .empty();
171        let response = state.request(request).await;
172        response.assert_status(StatusCode::NOT_FOUND);
173        let body: serde_json::Value = response.json();
174        assert_eq!(
175            body["errors"][0]["title"],
176            "User ID 01040G2081040G2081040G2081 not found"
177        );
178    }
179}