diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 463af20b..4cc74603 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use aide::axum::{routing::get_with, ApiRouter}; +use aide::axum::{ + routing::{get_with, post_with}, + ApiRouter, +}; use axum::extract::{FromRef, FromRequestParts}; use mas_matrix::BoxHomeserverConnection; use mas_storage::BoxRng; @@ -42,4 +45,8 @@ where "/users/by-username/:username", get_with(self::users::by_username, self::users::by_username_doc), ) + .api_route( + "/users/:id/deactivate", + post_with(self::users::deactivate, self::users::deactivate_doc), + ) } diff --git a/crates/handlers/src/admin/v1/users/deactivate.rs b/crates/handlers/src/admin/v1/users/deactivate.rs new file mode 100644 index 00000000..92cfd013 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/deactivate.rs @@ -0,0 +1,210 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use aide::{transform::TransformOperation, OperationIo}; +use axum::{response::IntoResponse, Json}; +use hyper::StatusCode; +use mas_storage::job::{DeactivateUserJob, JobRepositoryExt}; +use tracing::info; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, User}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("deactivateUser") + .summary("Deactivate a user") + .description("Calling this endpoint will lock and deactivate the user, preventing them from doing any action. +This invalidates any existing session, and will ask the homeserver to make them leave all rooms.") + .tag("user") + .response_with::<200, Json>, _>(|t| { + // In the samples, the third user is the one locked + let [_alice, _bob, charlie, ..] = User::samples(); + let id = charlie.id(); + let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate")); + t.description("User was deactivated").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User ID not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.users.deactivate", skip_all, err)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let mut user = repo + .user() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + if user.locked_at.is_none() { + user = repo.user().lock(&clock, user).await?; + } + + info!("Scheduling deactivation of user {}", user.id); + repo.job() + .schedule_job(DeactivateUserJob::new(&user, true)) + .await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + User::from(user), + format!("/api/admin/v1/users/{id}/deactivate"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_storage::{user::UserRepository, Clock, RepositoryAccess}; + use sqlx::{types::Json, PgPool}; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_deactivate_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The locked_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["locked_at"], + serde_json::json!(state.clock.now()) + ); + + // It should have scheduled a deactivation job for the user + // XXX: we don't have a good way to look for the deactivation job + let job: Json = + sqlx::query_scalar("SELECT job FROM apalis.jobs WHERE job_type = 'deactivate-user'") + .fetch_one(&pool) + .await + .expect("Deactivation job to be scheduled"); + assert_eq!(job["user_id"], serde_json::json!(user.id)); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_deactivate_locked_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + let user = repo.user().lock(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + // Move the clock forward to make sure the locked_at timestamp doesn't change + state.clock.advance(Duration::try_minutes(1).unwrap()); + + let request = Request::post(format!("/api/admin/v1/users/{}/deactivate", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The locked_at timestamp should be different from the current time + assert_ne!( + body["data"]["attributes"]["locked_at"], + serde_json::json!(state.clock.now()) + ); + + // It should have scheduled a deactivation job for the user + // XXX: we don't have a good way to look for the deactivation job + let job: Json = + sqlx::query_scalar("SELECT job FROM apalis.jobs WHERE job_type = 'deactivate-user'") + .fetch_one(&pool) + .await + .expect("Deactivation job to be scheduled"); + assert_eq!(job["user_id"], serde_json::json!(user.id)); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_deactivate_unknown_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/deactivate") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "User ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/users/mod.rs b/crates/handlers/src/admin/v1/users/mod.rs index 05615a40..7af62eb1 100644 --- a/crates/handlers/src/admin/v1/users/mod.rs +++ b/crates/handlers/src/admin/v1/users/mod.rs @@ -14,12 +14,14 @@ mod add; mod by_username; +mod deactivate; mod get; mod list; pub use self::{ add::{doc as add_doc, handler as add}, by_username::{doc as by_username_doc, handler as by_username}, + deactivate::{doc as deactivate_doc, handler as deactivate}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/docs/api/spec.json b/docs/api/spec.json index c1551a02..365e5420 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -378,6 +378,75 @@ } } } + }, + "/api/admin/v1/users/{id}/deactivate": { + "post": { + "tags": [ + "user" + ], + "summary": "Deactivate a user", + "description": "Calling this endpoint will lock and deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", + "operationId": "deactivateUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was deactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "030C1G60R30C1G60R30C1G60R3", + "attributes": { + "username": "charlie", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": "1970-01-01T00:00:00Z", + "can_request_admin": false + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" + } + }, + "links": { + "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3/deactivate" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } } }, "components": {