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

Commit e5b59ca

Browse files
committed
admin: add API to create users
1 parent 8737d6f commit e5b59ca

File tree

6 files changed

+305
-10
lines changed

6 files changed

+305
-10
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ use aide::{
1616
axum::ApiRouter,
1717
openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server, ServerVariable},
1818
};
19-
use axum::{extract::FromRequestParts, Json, Router};
19+
use axum::{
20+
extract::{FromRef, FromRequestParts},
21+
Json, Router,
22+
};
2023
use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
2124
use indexmap::IndexMap;
2225
use mas_http::CorsLayerExt;
26+
use mas_matrix::BoxHomeserverConnection;
2327
use mas_router::{OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, SimpleRoute};
28+
use mas_storage::BoxRng;
2429
use tower_http::cors::{Any, CorsLayer};
2530

2631
mod call_context;
@@ -34,6 +39,8 @@ use self::call_context::CallContext;
3439
pub fn router<S>() -> (OpenApi, Router<S>)
3540
where
3641
S: Clone + Send + Sync + 'static,
42+
BoxHomeserverConnection: FromRef<S>,
43+
BoxRng: FromRequestParts<S>,
3744
CallContext: FromRequestParts<S>,
3845
{
3946
let mut api = OpenApi::default();

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
// limitations under the License.
1414

1515
use aide::axum::{routing::get_with, ApiRouter};
16-
use axum::extract::FromRequestParts;
16+
use axum::extract::{FromRef, FromRequestParts};
17+
use mas_matrix::BoxHomeserverConnection;
18+
use mas_storage::BoxRng;
1719

1820
use super::call_context::CallContext;
1921

@@ -22,10 +24,16 @@ mod users;
2224
pub fn router<S>() -> ApiRouter<S>
2325
where
2426
S: Clone + Send + Sync + 'static,
27+
BoxHomeserverConnection: FromRef<S>,
28+
BoxRng: FromRequestParts<S>,
2529
CallContext: FromRequestParts<S>,
2630
{
2731
ApiRouter::<S>::new()
28-
.api_route("/users", get_with(self::users::list, self::users::list_doc))
32+
.api_route(
33+
"/users",
34+
get_with(self::users::list, self::users::list_doc)
35+
.post_with(self::users::add, self::users::add_doc),
36+
)
2937
.api_route(
3038
"/users/:id",
3139
get_with(self::users::get, self::users::get_doc),
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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, NoApi, OperationIo};
16+
use axum::{extract::State, response::IntoResponse, Json};
17+
use hyper::StatusCode;
18+
use mas_matrix::BoxHomeserverConnection;
19+
use mas_storage::{
20+
job::{JobRepositoryExt, ProvisionUserJob},
21+
BoxRng,
22+
};
23+
use schemars::JsonSchema;
24+
use serde::Deserialize;
25+
use tracing::warn;
26+
27+
use crate::{
28+
admin::{
29+
call_context::CallContext,
30+
model::User,
31+
response::{ErrorResponse, SingleResponse},
32+
},
33+
impl_from_error_for_route,
34+
};
35+
36+
fn valid_username_character(c: char) -> bool {
37+
c.is_ascii_lowercase()
38+
|| c.is_ascii_digit()
39+
|| c == '='
40+
|| c == '_'
41+
|| c == '-'
42+
|| c == '.'
43+
|| c == '/'
44+
|| c == '+'
45+
}
46+
47+
// XXX: this should be shared with the graphql handler
48+
fn username_valid(username: &str) -> bool {
49+
if username.is_empty() || username.len() > 255 {
50+
return false;
51+
}
52+
53+
// Should not start with an underscore
54+
if username.get(0..1) == Some("_") {
55+
return false;
56+
}
57+
58+
// Should only contain valid characters
59+
if !username.chars().all(valid_username_character) {
60+
return false;
61+
}
62+
63+
true
64+
}
65+
66+
#[derive(Debug, thiserror::Error, OperationIo)]
67+
#[aide(output_with = "Json<ErrorResponse>")]
68+
pub enum RouteError {
69+
#[error(transparent)]
70+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
71+
72+
#[error(transparent)]
73+
Homeserver(anyhow::Error),
74+
75+
#[error("Username is not valid")]
76+
UsernameNotValid,
77+
78+
#[error("User already exists")]
79+
UserAlreadyExists,
80+
81+
#[error("Username is reserved by the homeserver")]
82+
UsernameReserved,
83+
}
84+
85+
impl_from_error_for_route!(mas_storage::RepositoryError);
86+
87+
impl IntoResponse for RouteError {
88+
fn into_response(self) -> axum::response::Response {
89+
let error = ErrorResponse::from_error(&self);
90+
let status = match self {
91+
Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
92+
Self::UsernameNotValid => StatusCode::BAD_REQUEST,
93+
Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT,
94+
};
95+
(status, Json(error)).into_response()
96+
}
97+
}
98+
99+
#[derive(Deserialize, JsonSchema)]
100+
pub struct AddUserParams {
101+
/// The username of the user to add.
102+
username: String,
103+
104+
/// Skip checking with the homeserver whether the username is valid.
105+
///
106+
/// Use this with caution! The main reason to use this, is when a user used
107+
/// by an application service needs to exist in MAS to craft special
108+
/// tokens (like with admin access) for them
109+
#[serde(default)]
110+
skip_homeserver_check: bool,
111+
}
112+
113+
pub fn doc(operation: TransformOperation) -> TransformOperation {
114+
operation
115+
.summary("Create a new user")
116+
.tag("user")
117+
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
118+
let [sample, ..] = User::samples();
119+
let response = SingleResponse::new_canonical(sample);
120+
t.description("User was created").example(response)
121+
})
122+
.response_with::<400, RouteError, _>(|t| {
123+
let response = ErrorResponse::from_error(&RouteError::UsernameNotValid);
124+
t.description("Username is not valid").example(response)
125+
})
126+
.response_with::<409, RouteError, _>(|t| {
127+
let response = ErrorResponse::from_error(&RouteError::UserAlreadyExists);
128+
t.description("User already exists").example(response)
129+
})
130+
.response_with::<409, RouteError, _>(|t| {
131+
let response = ErrorResponse::from_error(&RouteError::UsernameReserved);
132+
t.description("Username is reserved by the homeserver")
133+
.example(response)
134+
})
135+
}
136+
137+
#[tracing::instrument(name = "handler.admin.v1.users.add", skip_all, err)]
138+
pub async fn handler(
139+
CallContext {
140+
mut repo, clock, ..
141+
}: CallContext,
142+
NoApi(mut rng): NoApi<BoxRng>,
143+
State(homeserver): State<BoxHomeserverConnection>,
144+
Json(params): Json<AddUserParams>,
145+
) -> Result<Json<SingleResponse<User>>, RouteError> {
146+
if repo.user().exists(&params.username).await? {
147+
return Err(RouteError::UserAlreadyExists);
148+
}
149+
150+
// Do some basic check on the username
151+
if !username_valid(&params.username) {
152+
return Err(RouteError::UsernameNotValid);
153+
}
154+
155+
// Ask the homeserver if the username is available
156+
let homeserver_available = homeserver
157+
.is_localpart_available(&params.username)
158+
.await
159+
.map_err(RouteError::Homeserver)?;
160+
161+
if !homeserver_available {
162+
if !params.skip_homeserver_check {
163+
return Err(RouteError::UsernameReserved);
164+
}
165+
166+
// If we skipped the check, we still want to shout about it
167+
warn!("Skipped homeserver check for username {}", params.username);
168+
}
169+
170+
let user = repo.user().add(&mut rng, &clock, params.username).await?;
171+
172+
repo.job()
173+
.schedule_job(ProvisionUserJob::new(&user))
174+
.await?;
175+
176+
repo.save().await?;
177+
178+
Ok(Json(SingleResponse::new_canonical(User::from(user))))
179+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
mod add;
1516
mod by_username;
1617
mod get;
1718
mod list;
1819

1920
pub use self::{
21+
add::{doc as add_doc, handler as add},
2022
by_username::{doc as by_username_doc, handler as by_username},
2123
get::{doc as get_doc, handler as get},
2224
list::{doc as list_doc, handler as list},

crates/handlers/src/bin/api-schema.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ macro_rules! impl_from_ref {
5757

5858
impl_from_request_parts!(mas_storage::BoxRepository);
5959
impl_from_request_parts!(mas_storage::BoxClock);
60+
impl_from_request_parts!(mas_storage::BoxRng);
6061
impl_from_request_parts!(mas_handlers::BoundActivityTracker);
62+
impl_from_ref!(mas_matrix::BoxHomeserverConnection);
6163
impl_from_ref!(mas_keystore::Keystore);
6264

6365
fn main() -> Result<(), Box<dyn std::error::Error>> {

docs/api.schema.json

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,86 @@
167167
}
168168
}
169169
}
170+
},
171+
"post": {
172+
"tags": [
173+
"user"
174+
],
175+
"summary": "Create a new user",
176+
"requestBody": {
177+
"content": {
178+
"application/json": {
179+
"schema": {
180+
"$ref": "#/components/schemas/AddUserParams"
181+
}
182+
}
183+
},
184+
"required": true
185+
},
186+
"responses": {
187+
"200": {
188+
"description": "User was created",
189+
"content": {
190+
"application/json": {
191+
"schema": {
192+
"$ref": "#/components/schemas/SingleResponse_for_User"
193+
},
194+
"example": {
195+
"data": {
196+
"type": "user",
197+
"id": "01040G2081040G2081040G2081",
198+
"attributes": {
199+
"username": "alice",
200+
"created_at": "1970-01-01T00:00:00Z",
201+
"locked_at": null,
202+
"can_request_admin": false
203+
},
204+
"links": {
205+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
206+
}
207+
},
208+
"links": {
209+
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
210+
}
211+
}
212+
}
213+
}
214+
},
215+
"400": {
216+
"description": "Username is not valid",
217+
"content": {
218+
"application/json": {
219+
"schema": {
220+
"$ref": "#/components/schemas/ErrorResponse"
221+
},
222+
"example": {
223+
"errors": [
224+
{
225+
"title": "Username is not valid"
226+
}
227+
]
228+
}
229+
}
230+
}
231+
},
232+
"409": {
233+
"description": "Username is reserved by the homeserver",
234+
"content": {
235+
"application/json": {
236+
"schema": {
237+
"$ref": "#/components/schemas/ErrorResponse"
238+
},
239+
"example": {
240+
"errors": [
241+
{
242+
"title": "Username is reserved by the homeserver"
243+
}
244+
]
245+
}
246+
}
247+
}
248+
}
249+
}
170250
}
171251
},
172252
"/api/admin/v1/users/{id}": {
@@ -581,17 +661,20 @@
581661
}
582662
}
583663
},
584-
"UlidInPath": {
664+
"AddUserParams": {
585665
"type": "object",
586666
"required": [
587-
"id"
667+
"username"
588668
],
589669
"properties": {
590-
"id": {
591-
"title": "ULID",
592-
"description": "A ULID as per https://github.com/ulid/spec",
593-
"type": "string",
594-
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
670+
"username": {
671+
"description": "The username of the user to add.",
672+
"type": "string"
673+
},
674+
"skip_homeserver_check": {
675+
"description": "Skip checking with the homeserver whether the username is valid.\n\nUse this with caution! The main reason to use this, is when a user used by an application service needs to exist in MAS to craft special tokens (like with admin access) for them",
676+
"default": false,
677+
"type": "boolean"
595678
}
596679
}
597680
},
@@ -611,6 +694,20 @@
611694
}
612695
}
613696
},
697+
"UlidInPath": {
698+
"type": "object",
699+
"required": [
700+
"id"
701+
],
702+
"properties": {
703+
"id": {
704+
"title": "ULID",
705+
"description": "A ULID as per https://github.com/ulid/spec",
706+
"type": "string",
707+
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
708+
}
709+
}
710+
},
614711
"UsernamePathParam": {
615712
"type": "object",
616713
"required": [

0 commit comments

Comments
 (0)