diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 5f13db2fa..e481f2add 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -54,7 +54,7 @@ pub struct User { locked_at: Option>, /// Whether the user can request admin privileges. - can_request_admin: bool, + admin: bool, } impl User { @@ -66,21 +66,21 @@ impl User { username: "alice".to_owned(), created_at: DateTime::default(), locked_at: None, - can_request_admin: false, + admin: false, }, Self { id: Ulid::from_bytes([0x02; 16]), username: "bob".to_owned(), created_at: DateTime::default(), locked_at: None, - can_request_admin: true, + admin: true, }, Self { id: Ulid::from_bytes([0x03; 16]), username: "charlie".to_owned(), created_at: DateTime::default(), locked_at: Some(DateTime::default()), - can_request_admin: false, + admin: false, }, ] } @@ -93,7 +93,7 @@ impl From for User { username: user.username, created_at: user.created_at, locked_at: user.locked_at, - can_request_admin: user.can_request_admin, + admin: user.can_request_admin, } } } diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 38f9059fe..9e668140f 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -60,6 +60,10 @@ where "/users/by-username/:username", get_with(self::users::by_username, self::users::by_username_doc), ) + .api_route( + "/users/:id/set-admin", + post_with(self::users::set_admin, self::users::set_admin_doc), + ) .api_route( "/users/:id/deactivate", post_with(self::users::deactivate, self::users::deactivate_doc), diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs index 4c3fbb534..d5f7dda63 100644 --- a/crates/handlers/src/admin/v1/users/list.rs +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -55,9 +55,9 @@ impl std::fmt::Display for UserStatus { #[aide(input_with = "Query")] #[from_request(via(Query), rejection(RouteError))] pub struct FilterParams { - /// Retrieve users with (or without) the `can_request_admin` flag set - #[serde(rename = "filter[can_request_admin]")] - can_request_admin: Option, + /// Retrieve users with (or without) the `admin` flag set + #[serde(rename = "filter[admin]")] + admin: Option, /// Retrieve the items with the given status /// @@ -74,8 +74,8 @@ impl std::fmt::Display for FilterParams { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut sep = '?'; - if let Some(can_request_admin) = self.can_request_admin { - write!(f, "{sep}filter[can_request_admin]={can_request_admin}")?; + if let Some(admin) = self.admin { + write!(f, "{sep}filter[admin]={admin}")?; sep = '&'; } if let Some(status) = self.status { @@ -139,7 +139,7 @@ pub async fn handler( let base = format!("{path}{params}", path = User::PATH); let filter = UserFilter::default(); - let filter = match params.can_request_admin { + let filter = match params.admin { Some(true) => filter.can_request_admin_only(), Some(false) => filter.cannot_request_admin_only(), None => filter, diff --git a/crates/handlers/src/admin/v1/users/mod.rs b/crates/handlers/src/admin/v1/users/mod.rs index 859c63a6f..553021719 100644 --- a/crates/handlers/src/admin/v1/users/mod.rs +++ b/crates/handlers/src/admin/v1/users/mod.rs @@ -18,6 +18,7 @@ mod deactivate; mod get; mod list; mod lock; +mod set_admin; mod set_password; mod unlock; @@ -28,6 +29,7 @@ pub use self::{ get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, lock::{doc as lock_doc, handler as lock}, + set_admin::{doc as set_admin_doc, handler as set_admin}, set_password::{doc as set_password_doc, handler as set_password}, unlock::{doc as unlock_doc, handler as unlock}, }; diff --git a/crates/handlers/src/admin/v1/users/set_admin.rs b/crates/handlers/src/admin/v1/users/set_admin.rs new file mode 100644 index 000000000..d75731e81 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/set_admin.rs @@ -0,0 +1,167 @@ +// 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 schemars::JsonSchema; +use serde::Deserialize; +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() + } +} + +/// # JSON payload for the `POST /api/admin/v1/users/:id/set-admin` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "UserSetAdminRequest")] +pub struct Request { + /// Whether the user can request admin privileges. + admin: bool, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("userSetAdmin") + .summary("Set whether a user can request admin") + .description("Calling this endpoint will not have any effect on existing sessions, meaning that their existing sessions will keep admin access if they were granted it.") + .tag("user") + .response_with::<200, Json>, _>(|t| { + // In the samples, the second user is the one which can request admin + let [_alice, bob, ..] = User::samples(); + let id = bob.id(); + let response = SingleResponse::new(bob, format!("/api/admin/v1/users/{id}/set-admin")); + t.description("User had admin privileges set").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.set_admin", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, + Json(params): Json, +) -> Result>, RouteError> { + let id = *id; + let user = repo + .user() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + let user = repo + .user() + .set_can_request_admin(user, params.admin) + .await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + User::from(user), + format!("/api/admin/v1/users/{id}/set-admin"), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_storage::{user::UserRepository, RepositoryAccess}; + use sqlx::PgPool; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_change_can_request_admin(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).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/{}/set-admin", user.id)) + .bearer(&token) + .json(serde_json::json!({ + "admin": true, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_eq!(body["data"]["attributes"]["admin"], true); + + // Look at the state from the repository + let mut repo = state.repository().await.unwrap(); + let user = repo.user().lookup(user.id).await.unwrap().unwrap(); + assert!(user.can_request_admin); + repo.save().await.unwrap(); + + // Flip it back + let request = Request::post(format!("/api/admin/v1/users/{}/set-admin", user.id)) + .bearer(&token) + .json(serde_json::json!({ + "admin": false, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_eq!(body["data"]["attributes"]["admin"], false); + + // Look at the state from the repository + let mut repo = state.repository().await.unwrap(); + let user = repo.user().lookup(user.id).await.unwrap().unwrap(); + assert!(!user.can_request_admin); + repo.save().await.unwrap(); + } +} diff --git a/docs/api/spec.json b/docs/api/spec.json index 6f7204922..e2dc48831 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -378,10 +378,10 @@ }, { "in": "query", - "name": "filter[can_request_admin]", - "description": "Retrieve users with (or without) the `can_request_admin` flag set", + "name": "filter[admin]", + "description": "Retrieve users with (or without) the `admin` flag set", "schema": { - "description": "Retrieve users with (or without) the `can_request_admin` flag set", + "description": "Retrieve users with (or without) the `admin` flag set", "type": "boolean", "nullable": true }, @@ -419,7 +419,7 @@ "username": "alice", "created_at": "1970-01-01T00:00:00Z", "locked_at": null, - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -432,7 +432,7 @@ "username": "bob", "created_at": "1970-01-01T00:00:00Z", "locked_at": null, - "can_request_admin": true + "admin": true }, "links": { "self": "/api/admin/v1/users/02081040G2081040G2081040G2" @@ -445,7 +445,7 @@ "username": "charlie", "created_at": "1970-01-01T00:00:00Z", "locked_at": "1970-01-01T00:00:00Z", - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" @@ -496,7 +496,7 @@ "username": "alice", "created_at": "1970-01-01T00:00:00Z", "locked_at": null, - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -581,7 +581,7 @@ "username": "alice", "created_at": "1970-01-01T00:00:00Z", "locked_at": null, - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -744,7 +744,7 @@ "username": "alice", "created_at": "1970-01-01T00:00:00Z", "locked_at": null, - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -777,6 +777,85 @@ } } }, + "/api/admin/v1/users/{id}/set-admin": { + "post": { + "tags": [ + "user" + ], + "summary": "Set whether a user can request admin", + "description": "Calling this endpoint will not have any effect on existing sessions, meaning that their existing sessions will keep admin access if they were granted it.", + "operationId": "userSetAdmin", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSetAdminRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User had admin privileges set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "02081040G2081040G2081040G2", + "attributes": { + "username": "bob", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "admin": true + }, + "links": { + "self": "/api/admin/v1/users/02081040G2081040G2081040G2" + } + }, + "links": { + "self": "/api/admin/v1/users/02081040G2081040G2081040G2/set-admin" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/users/{id}/deactivate": { "post": { "tags": [ @@ -813,7 +892,7 @@ "username": "charlie", "created_at": "1970-01-01T00:00:00Z", "locked_at": "1970-01-01T00:00:00Z", - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" @@ -882,7 +961,7 @@ "username": "charlie", "created_at": "1970-01-01T00:00:00Z", "locked_at": "1970-01-01T00:00:00Z", - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3" @@ -950,7 +1029,7 @@ "username": "alice", "created_at": "1970-01-01T00:00:00Z", "locked_at": null, - "can_request_admin": false + "admin": false }, "links": { "self": "/api/admin/v1/users/01040G2081040G2081040G2081" @@ -1318,8 +1397,8 @@ "UserFilter": { "type": "object", "properties": { - "filter[can_request_admin]": { - "description": "Retrieve users with (or without) the `can_request_admin` flag set", + "filter[admin]": { + "description": "Retrieve users with (or without) the `admin` flag set", "type": "boolean", "nullable": true }, @@ -1395,7 +1474,7 @@ "description": "A user", "type": "object", "required": [ - "can_request_admin", + "admin", "created_at", "username" ], @@ -1415,7 +1494,7 @@ "format": "date-time", "nullable": true }, - "can_request_admin": { + "admin": { "description": "Whether the user can request admin privileges.", "type": "boolean" } @@ -1487,6 +1566,19 @@ "type": "string" } } + }, + "UserSetAdminRequest": { + "title": "JSON payload for the `POST /api/admin/v1/users/:id/set-admin` endpoint", + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "description": "Whether the user can request admin privileges.", + "type": "boolean" + } + } } } },