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

Add admin API to create users #3019

Merged
merged 5 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use indexmap::IndexMap;
use mas_axum_utils::FancyError;
use mas_http::CorsLayerExt;
use mas_matrix::BoxHomeserverConnection;
use mas_router::{
ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
UrlBuilder,
};
use mas_storage::BoxRng;
use mas_templates::{ApiDocContext, Templates};
use tower_http::cors::{Any, CorsLayer};

Expand All @@ -45,6 +47,8 @@ use self::call_context::CallContext;
pub fn router<S>() -> (OpenApi, Router<S>)
where
S: Clone + Send + Sync + 'static,
BoxHomeserverConnection: FromRef<S>,
BoxRng: FromRequestParts<S>,
CallContext: FromRequestParts<S>,
Templates: FromRef<S>,
UrlBuilder: FromRef<S>,
Expand Down
12 changes: 10 additions & 2 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
// limitations under the License.

use aide::axum::{routing::get_with, ApiRouter};
use axum::extract::FromRequestParts;
use axum::extract::{FromRef, FromRequestParts};
use mas_matrix::BoxHomeserverConnection;
use mas_storage::BoxRng;

use super::call_context::CallContext;

Expand All @@ -22,10 +24,16 @@ mod users;
pub fn router<S>() -> ApiRouter<S>
where
S: Clone + Send + Sync + 'static,
BoxHomeserverConnection: FromRef<S>,
BoxRng: FromRequestParts<S>,
CallContext: FromRequestParts<S>,
{
ApiRouter::<S>::new()
.api_route("/users", get_with(self::users::list, self::users::list_doc))
.api_route(
"/users",
get_with(self::users::list, self::users::list_doc)
.post_with(self::users::add, self::users::add_doc),
)
.api_route(
"/users/:id",
get_with(self::users::get, self::users::get_doc),
Expand Down
324 changes: 324 additions & 0 deletions crates/handlers/src/admin/v1/users/add.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use aide::{transform::TransformOperation, NoApi, OperationIo};
use axum::{extract::State, response::IntoResponse, Json};
use hyper::StatusCode;
use mas_matrix::BoxHomeserverConnection;
use mas_storage::{
job::{JobRepositoryExt, ProvisionUserJob},
BoxRng,
};
use schemars::JsonSchema;
use serde::Deserialize;
use tracing::warn;

use crate::{
admin::{
call_context::CallContext,
model::User,
response::{ErrorResponse, SingleResponse},
},
impl_from_error_for_route,
};

fn valid_username_character(c: char) -> bool {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| c == '='
|| c == '_'
|| c == '-'
|| c == '.'
|| c == '/'
|| c == '+'
}

// XXX: this should be shared with the graphql handler
fn username_valid(username: &str) -> bool {
if username.is_empty() || username.len() > 255 {
return false;
}

// Should not start with an underscore
if username.starts_with('_') {
return false;
}

// Should only contain valid characters
if !username.chars().all(valid_username_character) {
return false;
}

true
}

#[derive(Debug, thiserror::Error, OperationIo)]
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[error(transparent)]
Homeserver(anyhow::Error),

#[error("Username is not valid")]
UsernameNotValid,

#[error("User already exists")]
UserAlreadyExists,

#[error("Username is reserved by the homeserver")]
UsernameReserved,
}

impl_from_error_for_route!(mas_storage::RepositoryError);

impl IntoResponse for RouteError {
fn into_response(self) -> axum::response::Response {
let error = ErrorResponse::from_error(&self);
let status = match self {
Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::UsernameNotValid => StatusCode::BAD_REQUEST,
Self::UserAlreadyExists | Self::UsernameReserved => StatusCode::CONFLICT,
};
(status, Json(error)).into_response()
}
}

/// # JSON payload for the `POST /api/admin/v1/users` endpoint
sandhose marked this conversation as resolved.
Show resolved Hide resolved
#[derive(Deserialize, JsonSchema)]
#[serde(rename = "AddUserRequest")]
pub struct Request {
/// The username of the user to add.
username: String,

/// Skip checking with the homeserver whether the username is available.
///
/// Use 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
Comment on lines +108 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I.... don't understand

Copy link
Member Author

@sandhose sandhose Aug 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use case for this is if you want to create a user on the MAS side for a user reserved by an application service, or a user which for some reason already exists in Synapse but not in MAS

For the application service, it's useful if you need a token for a AS user which also has Synapse admin access

Tweaked the wording to say 'available' instead of 'valid'

#[serde(default)]
skip_homeserver_check: bool,
}

pub fn doc(operation: TransformOperation) -> TransformOperation {
operation
.id("createUser")
.summary("Create a new user")
.tag("user")
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
let [sample, ..] = User::samples();
let response = SingleResponse::new_canonical(sample);
t.description("User was created").example(response)
})
.response_with::<400, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::UsernameNotValid);
t.description("Username is not valid").example(response)
})
.response_with::<409, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::UserAlreadyExists);
t.description("User already exists").example(response)
})
.response_with::<409, RouteError, _>(|t| {
let response = ErrorResponse::from_error(&RouteError::UsernameReserved);
t.description("Username is reserved by the homeserver")
.example(response)
})
}

#[tracing::instrument(name = "handler.admin.v1.users.add", skip_all, err)]
pub async fn handler(
CallContext {
mut repo, clock, ..
}: CallContext,
NoApi(mut rng): NoApi<BoxRng>,
State(homeserver): State<BoxHomeserverConnection>,
Json(params): Json<Request>,
) -> Result<Json<SingleResponse<User>>, RouteError> {
if repo.user().exists(&params.username).await? {
return Err(RouteError::UserAlreadyExists);
}

// Do some basic check on the username
if !username_valid(&params.username) {
return Err(RouteError::UsernameNotValid);
}

// Ask the homeserver if the username is available
let homeserver_available = homeserver
.is_localpart_available(&params.username)
.await
.map_err(RouteError::Homeserver)?;

if !homeserver_available {
if !params.skip_homeserver_check {
return Err(RouteError::UsernameReserved);
}

// If we skipped the check, we still want to shout about it
warn!("Skipped homeserver check for username {}", params.username);
}

let user = repo.user().add(&mut rng, &clock, params.username).await?;

repo.job()
.schedule_job(ProvisionUserJob::new(&user))
.await?;

repo.save().await?;

Ok(Json(SingleResponse::new_canonical(User::from(user))))
}

#[cfg(test)]
mod tests {
use hyper::{Request, StatusCode};
use mas_storage::{user::UserRepository, RepositoryAccess};
use sqlx::PgPool;

use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_add_user(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;

let request = Request::post("/api/admin/v1/users")
.bearer(&token)
.json(serde_json::json!({
"username": "alice",
}));

let response = state.request(request).await;
response.assert_status(StatusCode::OK);

let body: serde_json::Value = response.json();
assert_eq!(body["data"]["type"], "user");
let id = body["data"]["id"].as_str().unwrap();
assert_eq!(body["data"]["attributes"]["username"], "alice");

// Check that the user was created in the database
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.lookup(id.parse().unwrap())
.await
.unwrap()
.unwrap();

assert_eq!(user.username, "alice");
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_add_user_invalid_username(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;

let request = Request::post("/api/admin/v1/users")
.bearer(&token)
.json(serde_json::json!({
"username": "this is invalid",
}));

let response = state.request(request).await;
response.assert_status(StatusCode::BAD_REQUEST);

let body: serde_json::Value = response.json();
assert_eq!(body["errors"][0]["title"], "Username is not valid");
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_add_user_exists(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;

let request = Request::post("/api/admin/v1/users")
.bearer(&token)
.json(serde_json::json!({
"username": "alice",
}));

let response = state.request(request).await;
response.assert_status(StatusCode::OK);

let body: serde_json::Value = response.json();
assert_eq!(body["data"]["type"], "user");
assert_eq!(body["data"]["attributes"]["username"], "alice");

let request = Request::post("/api/admin/v1/users")
.bearer(&token)
.json(serde_json::json!({
"username": "alice",
}));

let response = state.request(request).await;
response.assert_status(StatusCode::CONFLICT);

let body: serde_json::Value = response.json();
assert_eq!(body["errors"][0]["title"], "User already exists");
}

#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_add_user_reserved(pool: PgPool) {
setup();
let mut state = TestState::from_pool(pool).await.unwrap();
let token = state.token_with_scope("urn:mas:admin").await;

// Reserve a username on the homeserver and try to add it
state.homeserver_connection.reserve_localpart("bob").await;

let request = Request::post("/api/admin/v1/users")
.bearer(&token)
.json(serde_json::json!({
"username": "bob",
}));

let response = state.request(request).await;

let body: serde_json::Value = response.json();
assert_eq!(
body["errors"][0]["title"],
"Username is reserved by the homeserver"
);

// But we can force it with the skip_homeserver_check flag
let request = Request::post("/api/admin/v1/users")
.bearer(&token)
.json(serde_json::json!({
"username": "bob",
"skip_homeserver_check": true,
}));

let response = state.request(request).await;
response.assert_status(StatusCode::OK);

let body: serde_json::Value = response.json();
let id = body["data"]["id"].as_str().unwrap();
assert_eq!(body["data"]["attributes"]["username"], "bob");

// Check that the user was created in the database
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.lookup(id.parse().unwrap())
.await
.unwrap()
.unwrap();

assert_eq!(user.username, "bob");
}
}
2 changes: 2 additions & 0 deletions crates/handlers/src/admin/v1/users/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod add;
mod by_username;
mod get;
mod list;

pub use self::{
add::{doc as add_doc, handler as add},
by_username::{doc as by_username_doc, handler as by_username},
get::{doc as get_doc, handler as get},
list::{doc as list_doc, handler as list},
Expand Down
Loading
Loading