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

Commit 559816b

Browse files
committed
admin: user unlock API
1 parent cc10495 commit 559816b

File tree

4 files changed

+296
-1
lines changed

4 files changed

+296
-1
lines changed

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
use aide::axum::{routing::get_with, ApiRouter};
15+
use aide::axum::{
16+
routing::{get_with, post_with},
17+
ApiRouter,
18+
};
1619
use axum::extract::{FromRef, FromRequestParts};
1720
use mas_matrix::BoxHomeserverConnection;
1821
use mas_storage::BoxRng;
@@ -42,4 +45,8 @@ where
4245
"/users/by-username/:username",
4346
get_with(self::users::by_username, self::users::by_username_doc),
4447
)
48+
.api_route(
49+
"/users/:id/unlock",
50+
post_with(self::users::unlock, self::users::unlock_doc),
51+
)
4552
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ mod add;
1616
mod by_username;
1717
mod get;
1818
mod list;
19+
mod unlock;
1920

2021
pub use self::{
2122
add::{doc as add_doc, handler as add},
2223
by_username::{doc as by_username_doc, handler as by_username},
2324
get::{doc as get_doc, handler as get},
2425
list::{doc as list_doc, handler as list},
26+
unlock::{doc as unlock_doc, handler as unlock},
2527
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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::{extract::State, response::IntoResponse, Json};
17+
use hyper::StatusCode;
18+
use mas_matrix::BoxHomeserverConnection;
19+
use ulid::Ulid;
20+
21+
use crate::{
22+
admin::{
23+
call_context::CallContext,
24+
model::{Resource, User},
25+
params::UlidPathParam,
26+
response::{ErrorResponse, SingleResponse},
27+
},
28+
impl_from_error_for_route,
29+
};
30+
31+
#[derive(Debug, thiserror::Error, OperationIo)]
32+
#[aide(output_with = "Json<ErrorResponse>")]
33+
pub enum RouteError {
34+
#[error(transparent)]
35+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
36+
37+
#[error(transparent)]
38+
Homeserver(anyhow::Error),
39+
40+
#[error("User ID {0} not found")]
41+
NotFound(Ulid),
42+
}
43+
44+
impl_from_error_for_route!(mas_storage::RepositoryError);
45+
46+
impl IntoResponse for RouteError {
47+
fn into_response(self) -> axum::response::Response {
48+
let error = ErrorResponse::from_error(&self);
49+
let status = match self {
50+
Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
51+
Self::NotFound(_) => StatusCode::NOT_FOUND,
52+
};
53+
(status, Json(error)).into_response()
54+
}
55+
}
56+
57+
pub fn doc(operation: TransformOperation) -> TransformOperation {
58+
operation
59+
.id("unlockUser")
60+
.summary("Unlock a user")
61+
.tag("user")
62+
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
63+
// In the samples, the third user is the one locked
64+
let [sample, ..] = User::samples();
65+
let id = sample.id();
66+
let response = SingleResponse::new(sample, format!("/api/admin/v1/users/{id}/unlock"));
67+
t.description("User was unlocked").example(response)
68+
})
69+
.response_with::<404, RouteError, _>(|t| {
70+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
71+
t.description("User ID not found").example(response)
72+
})
73+
}
74+
75+
#[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all, err)]
76+
pub async fn handler(
77+
CallContext { mut repo, .. }: CallContext,
78+
State(homeserver): State<BoxHomeserverConnection>,
79+
id: UlidPathParam,
80+
) -> Result<Json<SingleResponse<User>>, RouteError> {
81+
let id = *id;
82+
let user = repo
83+
.user()
84+
.lookup(id)
85+
.await?
86+
.ok_or(RouteError::NotFound(id))?;
87+
88+
// Call the homeserver synchronously to unlock the user
89+
let mxid = homeserver.mxid(&user.username);
90+
homeserver
91+
.reactivate_user(&mxid)
92+
.await
93+
.map_err(RouteError::Homeserver)?;
94+
95+
// Now unlock the user in our database
96+
let user = repo.user().unlock(user).await?;
97+
98+
repo.save().await?;
99+
100+
Ok(Json(SingleResponse::new(
101+
User::from(user),
102+
format!("/api/admin/v1/users/{id}/unlock"),
103+
)))
104+
}
105+
106+
#[cfg(test)]
107+
mod tests {
108+
use hyper::{Request, StatusCode};
109+
use mas_matrix::{HomeserverConnection, ProvisionRequest};
110+
use mas_storage::{user::UserRepository, RepositoryAccess};
111+
use sqlx::PgPool;
112+
113+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
114+
115+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
116+
async fn test_unlock_user(pool: PgPool) {
117+
setup();
118+
let mut state = TestState::from_pool(pool).await.unwrap();
119+
let token = state.token_with_scope("urn:mas:admin").await;
120+
121+
let mut repo = state.repository().await.unwrap();
122+
let user = repo
123+
.user()
124+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
125+
.await
126+
.unwrap();
127+
let user = repo.user().lock(&state.clock, user).await.unwrap();
128+
repo.save().await.unwrap();
129+
130+
// Also provision the user on the homeserver, because this endpoint will try to
131+
// reactivate it
132+
let mxid = state.homeserver_connection.mxid(&user.username);
133+
state
134+
.homeserver_connection
135+
.provision_user(&ProvisionRequest::new(&mxid, &user.sub))
136+
.await
137+
.unwrap();
138+
139+
let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
140+
.bearer(&token)
141+
.empty();
142+
let response = state.request(request).await;
143+
response.assert_status(StatusCode::OK);
144+
let body: serde_json::Value = response.json();
145+
146+
assert_eq!(
147+
body["data"]["attributes"]["locked_at"],
148+
serde_json::json!(null)
149+
);
150+
}
151+
152+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
153+
async fn test_unlock_deactivated_user(pool: PgPool) {
154+
setup();
155+
let mut state = TestState::from_pool(pool).await.unwrap();
156+
let token = state.token_with_scope("urn:mas:admin").await;
157+
158+
let mut repo = state.repository().await.unwrap();
159+
let user = repo
160+
.user()
161+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
162+
.await
163+
.unwrap();
164+
let user = repo.user().lock(&state.clock, user).await.unwrap();
165+
repo.save().await.unwrap();
166+
167+
// Provision the user on the homeserver
168+
let mxid = state.homeserver_connection.mxid(&user.username);
169+
state
170+
.homeserver_connection
171+
.provision_user(&ProvisionRequest::new(&mxid, &user.sub))
172+
.await
173+
.unwrap();
174+
// but then deactivate it
175+
state
176+
.homeserver_connection
177+
.delete_user(&mxid, true)
178+
.await
179+
.unwrap();
180+
181+
// The user should be deactivated on the homeserver
182+
let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
183+
assert!(mx_user.deactivated);
184+
185+
let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
186+
.bearer(&token)
187+
.empty();
188+
let response = state.request(request).await;
189+
response.assert_status(StatusCode::OK);
190+
let body: serde_json::Value = response.json();
191+
192+
assert_eq!(
193+
body["data"]["attributes"]["locked_at"],
194+
serde_json::json!(null)
195+
);
196+
// The user should be reactivated on the homeserver
197+
let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
198+
assert!(!mx_user.deactivated);
199+
}
200+
201+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
202+
async fn test_lock_unknown_user(pool: PgPool) {
203+
setup();
204+
let mut state = TestState::from_pool(pool).await.unwrap();
205+
let token = state.token_with_scope("urn:mas:admin").await;
206+
207+
let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/unlock")
208+
.bearer(&token)
209+
.empty();
210+
let response = state.request(request).await;
211+
response.assert_status(StatusCode::NOT_FOUND);
212+
let body: serde_json::Value = response.json();
213+
assert_eq!(
214+
body["errors"][0]["title"],
215+
"User ID 01040G2081040G2081040G2081 not found"
216+
);
217+
}
218+
}

docs/api/spec.json

+68
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,74 @@
378378
}
379379
}
380380
}
381+
},
382+
"/api/admin/v1/users/{id}/unlock": {
383+
"post": {
384+
"tags": [
385+
"user"
386+
],
387+
"summary": "Unlock a user",
388+
"operationId": "unlockUser",
389+
"parameters": [
390+
{
391+
"in": "path",
392+
"name": "id",
393+
"required": true,
394+
"schema": {
395+
"title": "The ID of the resource",
396+
"$ref": "#/components/schemas/ULID"
397+
},
398+
"style": "simple"
399+
}
400+
],
401+
"responses": {
402+
"200": {
403+
"description": "User was unlocked",
404+
"content": {
405+
"application/json": {
406+
"schema": {
407+
"$ref": "#/components/schemas/SingleResponse_for_User"
408+
},
409+
"example": {
410+
"data": {
411+
"type": "user",
412+
"id": "01040G2081040G2081040G2081",
413+
"attributes": {
414+
"username": "alice",
415+
"created_at": "1970-01-01T00:00:00Z",
416+
"locked_at": null,
417+
"can_request_admin": false
418+
},
419+
"links": {
420+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
421+
}
422+
},
423+
"links": {
424+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081/unlock"
425+
}
426+
}
427+
}
428+
}
429+
},
430+
"404": {
431+
"description": "User ID not found",
432+
"content": {
433+
"application/json": {
434+
"schema": {
435+
"$ref": "#/components/schemas/ErrorResponse"
436+
},
437+
"example": {
438+
"errors": [
439+
{
440+
"title": "User ID 00000000000000000000000000 not found"
441+
}
442+
]
443+
}
444+
}
445+
}
446+
}
447+
}
448+
}
381449
}
382450
},
383451
"components": {

0 commit comments

Comments
 (0)