From 2eaca3db221697becc8119a7716dbb14a27b62f0 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 3 Jun 2025 11:11:38 +0200 Subject: [PATCH] Admin API to create a new user registration token --- crates/handlers/src/admin/v1/mod.rs | 4 + .../admin/v1/user_registration_tokens/add.rs | 198 ++++++++++++++++++ .../admin/v1/user_registration_tokens/mod.rs | 2 + docs/api/spec.json | 74 +++++++ 4 files changed, 278 insertions(+) create mode 100644 crates/handlers/src/admin/v1/user_registration_tokens/add.rs diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 0257adf3b..815aa8b4b 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -125,6 +125,10 @@ where get_with( self::user_registration_tokens::list, self::user_registration_tokens::list_doc, + ) + .post_with( + self::user_registration_tokens::add, + self::user_registration_tokens::add_doc, ), ) .api_route( diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs new file mode 100644 index 000000000..50fcb110a --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs @@ -0,0 +1,198 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::{DateTime, Utc}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::BoxRng; +use rand::{Rng, distributions::Alphanumeric}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::UserRegistrationToken, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), +} + +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 sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # JSON payload for the `POST /api/admin/v1/user-registration-tokens` +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "AddUserRegistrationTokenRequest")] +pub struct Request { + /// The token string. If not provided, a random token will be generated. + token: Option, + + /// Maximum number of times this token can be used. If not provided, the + /// token can be used an unlimited number of times. + usage_limit: Option, + + /// When the token expires. If not provided, the token never expires. + expires_at: Option>, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("addUserRegistrationToken") + .summary("Create a new user registration token") + .tag("user-registration-token") + .response_with::<201, Json>, _>(|t| { + let [sample, ..] = UserRegistrationToken::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("A new user registration token was created") + .example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.post", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + Json(params): Json, +) -> Result<(StatusCode, Json>), RouteError> { + // Generate a random token if none was provided + let token = params.token.unwrap_or_else(|| { + (&mut rng) + .sample_iter(&Alphanumeric) + .take(12) + .map(char::from) + .collect() + }); + + let registration_token = repo + .user_registration_token() + .add( + &mut rng, + &clock, + token, + params.usage_limit, + params.expires_at, + ) + .await?; + + repo.save().await?; + + Ok(( + StatusCode::CREATED, + Json(SingleResponse::new_canonical(registration_token.into())), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use insta::assert_json_snapshot; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create(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/user-registration-tokens") + .bearer(&token) + .json(serde_json::json!({ + "token": "test_token_123", + "usage_limit": 5, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_token_123", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_auto_token(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/user-registration-tokens") + .bearer(&token) + .json(serde_json::json!({ + "usage_limit": 1 + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0QMGC989M0XSFVF2X", + "attributes": { + "token": "42oTpLoieH5I", + "usage_limit": 1, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0QMGC989M0XSFVF2X" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0QMGC989M0XSFVF2X" + } + } + "#); + } +} diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs index 126ff168a..93b3cdb5a 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -3,10 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +mod add; mod get; mod list; pub use self::{ + add::{doc as add_doc, handler as add}, get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, }; diff --git a/docs/api/spec.json b/docs/api/spec.json index ea1f9696c..c4df38f9e 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2290,6 +2290,56 @@ } } } + }, + "post": { + "tags": [ + "user-registration-token" + ], + "summary": "Create a new user registration token", + "operationId": "addUserRegistrationToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddUserRegistrationTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "A new user registration token was created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "usage_limit": 10, + "times_used": 5, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": "1970-01-01T00:00:00Z", + "expires_at": "1970-01-31T00:00:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + } + } + } + } + } } }, "/api/admin/v1/user-registration-tokens/{id}": { @@ -3949,6 +3999,30 @@ } } }, + "AddUserRegistrationTokenRequest": { + "title": "JSON payload for the `POST /api/admin/v1/user-registration-tokens`", + "type": "object", + "properties": { + "token": { + "description": "The token string. If not provided, a random token will be generated.", + "type": "string", + "nullable": true + }, + "usage_limit": { + "description": "Maximum number of times this token can be used. If not provided, the token can be used an unlimited number of times.", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + }, + "expires_at": { + "description": "When the token expires. If not provided, the token never expires.", + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, "SingleResponse_for_UserRegistrationToken": { "description": "A top-level response with a single resource", "type": "object",