Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 6189abe

Browse files
committed
admin: set can_request_admin API
1 parent 5d4a4a6 commit 6189abe

File tree

4 files changed

+265
-0
lines changed

4 files changed

+265
-0
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ where
6060
"/users/by-username/:username",
6161
get_with(self::users::by_username, self::users::by_username_doc),
6262
)
63+
.api_route(
64+
"/users/:id/set-admin",
65+
post_with(self::users::set_admin, self::users::set_admin_doc),
66+
)
6367
.api_route(
6468
"/users/:id/deactivate",
6569
post_with(self::users::deactivate, self::users::deactivate_doc),

crates/handlers/src/admin/v1/users/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod deactivate;
1818
mod get;
1919
mod list;
2020
mod lock;
21+
mod set_admin;
2122
mod set_password;
2223
mod unlock;
2324

@@ -28,6 +29,7 @@ pub use self::{
2829
get::{doc as get_doc, handler as get},
2930
list::{doc as list_doc, handler as list},
3031
lock::{doc as lock_doc, handler as lock},
32+
set_admin::{doc as set_admin_doc, handler as set_admin},
3133
set_password::{doc as set_password_doc, handler as set_password},
3234
unlock::{doc as unlock_doc, handler as unlock},
3335
};
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use aide::{transform::TransformOperation, OperationIo};
16+
use axum::{response::IntoResponse, Json};
17+
use hyper::StatusCode;
18+
use schemars::JsonSchema;
19+
use serde::Deserialize;
20+
use ulid::Ulid;
21+
22+
use crate::{
23+
admin::{
24+
call_context::CallContext,
25+
model::{Resource, User},
26+
params::UlidPathParam,
27+
response::{ErrorResponse, SingleResponse},
28+
},
29+
impl_from_error_for_route,
30+
};
31+
32+
#[derive(Debug, thiserror::Error, OperationIo)]
33+
#[aide(output_with = "Json<ErrorResponse>")]
34+
pub enum RouteError {
35+
#[error(transparent)]
36+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
37+
38+
#[error("User ID {0} not found")]
39+
NotFound(Ulid),
40+
}
41+
42+
impl_from_error_for_route!(mas_storage::RepositoryError);
43+
44+
impl IntoResponse for RouteError {
45+
fn into_response(self) -> axum::response::Response {
46+
let error = ErrorResponse::from_error(&self);
47+
let status = match self {
48+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
49+
Self::NotFound(_) => StatusCode::NOT_FOUND,
50+
};
51+
(status, Json(error)).into_response()
52+
}
53+
}
54+
55+
/// # JSON payload for the `POST /api/admin/v1/users/:id/set-admin` endpoint
56+
#[derive(Deserialize, JsonSchema)]
57+
#[serde(rename = "UserSetAdminRequest")]
58+
pub struct Request {
59+
/// Whether the user can request admin privileges.
60+
can_request_admin: bool,
61+
}
62+
63+
pub fn doc(operation: TransformOperation) -> TransformOperation {
64+
operation
65+
.id("userSetAdmin")
66+
.summary("Set whether a user can request admin")
67+
.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.")
68+
.tag("user")
69+
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
70+
// In the samples, the second user is the one which can request admin
71+
let [_alice, bob, ..] = User::samples();
72+
let id = bob.id();
73+
let response = SingleResponse::new(bob, format!("/api/admin/v1/users/{id}/set-admin"));
74+
t.description("User had admin privileges set").example(response)
75+
})
76+
.response_with::<404, RouteError, _>(|t| {
77+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
78+
t.description("User ID not found").example(response)
79+
})
80+
}
81+
82+
#[tracing::instrument(name = "handler.admin.v1.users.set_admin", skip_all, err)]
83+
pub async fn handler(
84+
CallContext { mut repo, .. }: CallContext,
85+
id: UlidPathParam,
86+
Json(params): Json<Request>,
87+
) -> Result<Json<SingleResponse<User>>, RouteError> {
88+
let id = *id;
89+
let user = repo
90+
.user()
91+
.lookup(id)
92+
.await?
93+
.ok_or(RouteError::NotFound(id))?;
94+
95+
let user = repo
96+
.user()
97+
.set_can_request_admin(user, params.can_request_admin)
98+
.await?;
99+
100+
repo.save().await?;
101+
102+
Ok(Json(SingleResponse::new(
103+
User::from(user),
104+
format!("/api/admin/v1/users/{id}/set-admin"),
105+
)))
106+
}
107+
108+
#[cfg(test)]
109+
mod tests {
110+
use hyper::{Request, StatusCode};
111+
use mas_storage::{user::UserRepository, RepositoryAccess};
112+
use sqlx::PgPool;
113+
114+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
115+
116+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
117+
async fn test_change_can_request_admin(pool: PgPool) {
118+
setup();
119+
let mut state = TestState::from_pool(pool).await.unwrap();
120+
let token = state.token_with_scope("urn:mas:admin").await;
121+
122+
let mut repo = state.repository().await.unwrap();
123+
let user = repo
124+
.user()
125+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
126+
.await
127+
.unwrap();
128+
repo.save().await.unwrap();
129+
130+
let request = Request::post(format!("/api/admin/v1/users/{}/set-admin", user.id))
131+
.bearer(&token)
132+
.json(serde_json::json!({
133+
"can_request_admin": true,
134+
}));
135+
136+
let response = state.request(request).await;
137+
response.assert_status(StatusCode::OK);
138+
let body: serde_json::Value = response.json();
139+
140+
assert_eq!(body["data"]["attributes"]["can_request_admin"], true);
141+
142+
// Look at the state from the repository
143+
let mut repo = state.repository().await.unwrap();
144+
let user = repo.user().lookup(user.id).await.unwrap().unwrap();
145+
assert!(user.can_request_admin);
146+
repo.save().await.unwrap();
147+
148+
// Flip it back
149+
let request = Request::post(format!("/api/admin/v1/users/{}/set-admin", user.id))
150+
.bearer(&token)
151+
.json(serde_json::json!({
152+
"can_request_admin": false,
153+
}));
154+
155+
let response = state.request(request).await;
156+
response.assert_status(StatusCode::OK);
157+
let body: serde_json::Value = response.json();
158+
159+
assert_eq!(body["data"]["attributes"]["can_request_admin"], false);
160+
161+
// Look at the state from the repository
162+
let mut repo = state.repository().await.unwrap();
163+
let user = repo.user().lookup(user.id).await.unwrap().unwrap();
164+
assert!(!user.can_request_admin);
165+
repo.save().await.unwrap();
166+
}
167+
}

docs/api/spec.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,85 @@
777777
}
778778
}
779779
},
780+
"/api/admin/v1/users/{id}/set-admin": {
781+
"post": {
782+
"tags": [
783+
"user"
784+
],
785+
"summary": "Set whether a user can request admin",
786+
"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.",
787+
"operationId": "userSetAdmin",
788+
"parameters": [
789+
{
790+
"in": "path",
791+
"name": "id",
792+
"required": true,
793+
"schema": {
794+
"title": "The ID of the resource",
795+
"$ref": "#/components/schemas/ULID"
796+
},
797+
"style": "simple"
798+
}
799+
],
800+
"requestBody": {
801+
"content": {
802+
"application/json": {
803+
"schema": {
804+
"$ref": "#/components/schemas/UserSetAdminRequest"
805+
}
806+
}
807+
},
808+
"required": true
809+
},
810+
"responses": {
811+
"200": {
812+
"description": "User had admin privileges set",
813+
"content": {
814+
"application/json": {
815+
"schema": {
816+
"$ref": "#/components/schemas/SingleResponse_for_User"
817+
},
818+
"example": {
819+
"data": {
820+
"type": "user",
821+
"id": "02081040G2081040G2081040G2",
822+
"attributes": {
823+
"username": "bob",
824+
"created_at": "1970-01-01T00:00:00Z",
825+
"locked_at": null,
826+
"can_request_admin": true
827+
},
828+
"links": {
829+
"self": "/api/admin/v1/users/02081040G2081040G2081040G2"
830+
}
831+
},
832+
"links": {
833+
"self": "/api/admin/v1/users/02081040G2081040G2081040G2/set-admin"
834+
}
835+
}
836+
}
837+
}
838+
},
839+
"404": {
840+
"description": "User ID not found",
841+
"content": {
842+
"application/json": {
843+
"schema": {
844+
"$ref": "#/components/schemas/ErrorResponse"
845+
},
846+
"example": {
847+
"errors": [
848+
{
849+
"title": "User ID 00000000000000000000000000 not found"
850+
}
851+
]
852+
}
853+
}
854+
}
855+
}
856+
}
857+
}
858+
},
780859
"/api/admin/v1/users/{id}/deactivate": {
781860
"post": {
782861
"tags": [
@@ -1487,6 +1566,19 @@
14871566
"type": "string"
14881567
}
14891568
}
1569+
},
1570+
"UserSetAdminRequest": {
1571+
"title": "JSON payload for the `POST /api/admin/v1/users/:id/set-admin` endpoint",
1572+
"type": "object",
1573+
"required": [
1574+
"can_request_admin"
1575+
],
1576+
"properties": {
1577+
"can_request_admin": {
1578+
"description": "Whether the user can request admin privileges.",
1579+
"type": "boolean"
1580+
}
1581+
}
14901582
}
14911583
}
14921584
},

0 commit comments

Comments
 (0)