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

Commit 21d1547

Browse files
committed
admin: user unlock API
1 parent f382846 commit 21d1547

File tree

4 files changed

+294
-1
lines changed

4 files changed

+294
-1
lines changed

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

Lines changed: 8 additions & 1 deletion
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

Lines changed: 2 additions & 0 deletions
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
};
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
.summary("Unlock a user")
60+
.tag("user")
61+
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
62+
// In the samples, the third user is the one locked
63+
let [sample, ..] = User::samples();
64+
let id = sample.id();
65+
let response = SingleResponse::new(sample, format!("/api/admin/v1/users/{id}/unlock"));
66+
t.description("User was unlocked").example(response)
67+
})
68+
.response_with::<404, RouteError, _>(|t| {
69+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
70+
t.description("User ID not found").example(response)
71+
})
72+
}
73+
74+
#[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all, err)]
75+
pub async fn handler(
76+
CallContext { mut repo, .. }: CallContext,
77+
State(homeserver): State<BoxHomeserverConnection>,
78+
id: UlidPathParam,
79+
) -> Result<Json<SingleResponse<User>>, RouteError> {
80+
let id = *id;
81+
let user = repo
82+
.user()
83+
.lookup(id)
84+
.await?
85+
.ok_or(RouteError::NotFound(id))?;
86+
87+
// Call the homeserver synchronously to unlock the user
88+
let mxid = homeserver.mxid(&user.username);
89+
homeserver
90+
.reactivate_user(&mxid)
91+
.await
92+
.map_err(RouteError::Homeserver)?;
93+
94+
// Now unlock the user in our database
95+
let user = repo.user().unlock(user).await?;
96+
97+
repo.save().await?;
98+
99+
Ok(Json(SingleResponse::new(
100+
User::from(user),
101+
format!("/api/admin/v1/users/{id}/unlock"),
102+
)))
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use hyper::{Request, StatusCode};
108+
use mas_matrix::{HomeserverConnection, ProvisionRequest};
109+
use mas_storage::{user::UserRepository, RepositoryAccess};
110+
use sqlx::PgPool;
111+
112+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
113+
114+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
115+
async fn test_unlock_user(pool: PgPool) {
116+
setup();
117+
let mut state = TestState::from_pool(pool).await.unwrap();
118+
let token = state.token_with_scope("urn:mas:admin").await;
119+
120+
let mut repo = state.repository().await.unwrap();
121+
let user = repo
122+
.user()
123+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
124+
.await
125+
.unwrap();
126+
let user = repo.user().lock(&state.clock, user).await.unwrap();
127+
repo.save().await.unwrap();
128+
129+
// Also provision the user on the homeserver, because this endpoint will try to
130+
// reactivate it
131+
let mxid = state.homeserver_connection.mxid(&user.username);
132+
state
133+
.homeserver_connection
134+
.provision_user(&ProvisionRequest::new(&mxid, &user.sub))
135+
.await
136+
.unwrap();
137+
138+
let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
139+
.bearer(&token)
140+
.empty();
141+
let response = state.request(request).await;
142+
response.assert_status(StatusCode::OK);
143+
let body: serde_json::Value = response.json();
144+
145+
assert_eq!(
146+
body["data"]["attributes"]["locked_at"],
147+
serde_json::json!(null)
148+
);
149+
}
150+
151+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
152+
async fn test_unlock_deactivated_user(pool: PgPool) {
153+
setup();
154+
let mut state = TestState::from_pool(pool).await.unwrap();
155+
let token = state.token_with_scope("urn:mas:admin").await;
156+
157+
let mut repo = state.repository().await.unwrap();
158+
let user = repo
159+
.user()
160+
.add(&mut state.rng(), &state.clock, "alice".to_owned())
161+
.await
162+
.unwrap();
163+
let user = repo.user().lock(&state.clock, user).await.unwrap();
164+
repo.save().await.unwrap();
165+
166+
// Provision the user on the homeserver
167+
let mxid = state.homeserver_connection.mxid(&user.username);
168+
state
169+
.homeserver_connection
170+
.provision_user(&ProvisionRequest::new(&mxid, &user.sub))
171+
.await
172+
.unwrap();
173+
// but then deactivate it
174+
state
175+
.homeserver_connection
176+
.delete_user(&mxid, true)
177+
.await
178+
.unwrap();
179+
180+
// The user should be deactivated on the homeserver
181+
let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
182+
assert!(mx_user.deactivated);
183+
184+
let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
185+
.bearer(&token)
186+
.empty();
187+
let response = state.request(request).await;
188+
response.assert_status(StatusCode::OK);
189+
let body: serde_json::Value = response.json();
190+
191+
assert_eq!(
192+
body["data"]["attributes"]["locked_at"],
193+
serde_json::json!(null)
194+
);
195+
// The user should be reactivated on the homeserver
196+
let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap();
197+
assert!(!mx_user.deactivated);
198+
}
199+
200+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
201+
async fn test_lock_unknown_user(pool: PgPool) {
202+
setup();
203+
let mut state = TestState::from_pool(pool).await.unwrap();
204+
let token = state.token_with_scope("urn:mas:admin").await;
205+
206+
let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/unlock")
207+
.bearer(&token)
208+
.empty();
209+
let response = state.request(request).await;
210+
response.assert_status(StatusCode::NOT_FOUND);
211+
let body: serde_json::Value = response.json();
212+
assert_eq!(
213+
body["errors"][0]["title"],
214+
"User ID 01040G2081040G2081040G2081 not found"
215+
);
216+
}
217+
}

docs/api/spec.json

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,73 @@
370370
}
371371
}
372372
}
373+
},
374+
"/api/admin/v1/users/{id}/unlock": {
375+
"post": {
376+
"tags": [
377+
"user"
378+
],
379+
"summary": "Unlock a user",
380+
"parameters": [
381+
{
382+
"in": "path",
383+
"name": "id",
384+
"required": true,
385+
"schema": {
386+
"title": "The ID of the resource",
387+
"$ref": "#/components/schemas/ULID"
388+
},
389+
"style": "simple"
390+
}
391+
],
392+
"responses": {
393+
"200": {
394+
"description": "User was unlocked",
395+
"content": {
396+
"application/json": {
397+
"schema": {
398+
"$ref": "#/components/schemas/SingleResponse_for_User"
399+
},
400+
"example": {
401+
"data": {
402+
"type": "user",
403+
"id": "01040G2081040G2081040G2081",
404+
"attributes": {
405+
"username": "alice",
406+
"created_at": "1970-01-01T00:00:00Z",
407+
"locked_at": null,
408+
"can_request_admin": false
409+
},
410+
"links": {
411+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
412+
}
413+
},
414+
"links": {
415+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081/unlock"
416+
}
417+
}
418+
}
419+
}
420+
},
421+
"404": {
422+
"description": "User ID not found",
423+
"content": {
424+
"application/json": {
425+
"schema": {
426+
"$ref": "#/components/schemas/ErrorResponse"
427+
},
428+
"example": {
429+
"errors": [
430+
{
431+
"title": "User ID 00000000000000000000000000 not found"
432+
}
433+
]
434+
}
435+
}
436+
}
437+
}
438+
}
439+
}
373440
}
374441
},
375442
"components": {

0 commit comments

Comments
 (0)