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