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

Commit 6bf1318

Browse files
committed
admin: user unlock API
1 parent 117e124 commit 6bf1318

File tree

4 files changed

+292
-0
lines changed

4 files changed

+292
-0
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,8 @@ where
4949
"/users/:id/deactivate",
5050
post_with(self::users::deactivate, self::users::deactivate_doc),
5151
)
52+
.api_route(
53+
"/users/:id/unlock",
54+
post_with(self::users::unlock, self::users::unlock_doc),
55+
)
5256
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ mod by_username;
1717
mod deactivate;
1818
mod get;
1919
mod list;
20+
mod unlock;
2021

2122
pub use self::{
2223
add::{doc as add_doc, handler as add},
2324
by_username::{doc as by_username_doc, handler as by_username},
2425
deactivate::{doc as deactivate_doc, handler as deactivate},
2526
get::{doc as get_doc, handler as get},
2627
list::{doc as list_doc, handler as list},
28+
unlock::{doc as unlock_doc, handler as unlock},
2729
};
Lines changed: 218 additions & 0 deletions
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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,74 @@
447447
}
448448
}
449449
}
450+
},
451+
"/api/admin/v1/users/{id}/unlock": {
452+
"post": {
453+
"tags": [
454+
"user"
455+
],
456+
"summary": "Unlock a user",
457+
"operationId": "unlockUser",
458+
"parameters": [
459+
{
460+
"in": "path",
461+
"name": "id",
462+
"required": true,
463+
"schema": {
464+
"title": "The ID of the resource",
465+
"$ref": "#/components/schemas/ULID"
466+
},
467+
"style": "simple"
468+
}
469+
],
470+
"responses": {
471+
"200": {
472+
"description": "User was unlocked",
473+
"content": {
474+
"application/json": {
475+
"schema": {
476+
"$ref": "#/components/schemas/SingleResponse_for_User"
477+
},
478+
"example": {
479+
"data": {
480+
"type": "user",
481+
"id": "01040G2081040G2081040G2081",
482+
"attributes": {
483+
"username": "alice",
484+
"created_at": "1970-01-01T00:00:00Z",
485+
"locked_at": null,
486+
"can_request_admin": false
487+
},
488+
"links": {
489+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
490+
}
491+
},
492+
"links": {
493+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081/unlock"
494+
}
495+
}
496+
}
497+
}
498+
},
499+
"404": {
500+
"description": "User ID not found",
501+
"content": {
502+
"application/json": {
503+
"schema": {
504+
"$ref": "#/components/schemas/ErrorResponse"
505+
},
506+
"example": {
507+
"errors": [
508+
{
509+
"title": "User ID 00000000000000000000000000 not found"
510+
}
511+
]
512+
}
513+
}
514+
}
515+
}
516+
}
517+
}
450518
}
451519
},
452520
"components": {

0 commit comments

Comments
 (0)