From c8f2a2146c7deb5e1cc2d7e03c2eedb417e6f137 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 5 Jun 2025 15:35:11 +0200 Subject: [PATCH 1/3] Admin API to un-revoke a user registration token. --- crates/handlers/src/admin/v1/mod.rs | 7 + .../admin/v1/user_registration_tokens/mod.rs | 2 + .../v1/user_registration_tokens/unrevoke.rs | 237 ++++++++++++++++++ ...1d78a7f026e8311bdc7d5ccc2f39d962e898f.json | 14 ++ .../storage-pg/src/user/registration_token.rs | 77 ++++++ crates/storage/src/user/registration_token.rs | 18 ++ docs/api/spec.json | 90 +++++++ 7 files changed, 445 insertions(+) create mode 100644 crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs create mode 100644 crates/storage-pg/.sqlx/query-3c7fc3e386ce51187f6344ad65e1d78a7f026e8311bdc7d5ccc2f39d962e898f.json diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 022aabdc3..be3f1922f 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -145,6 +145,13 @@ where self::user_registration_tokens::revoke_doc, ), ) + .api_route( + "/user-registration-tokens/{id}/unrevoke", + post_with( + self::user_registration_tokens::unrevoke, + self::user_registration_tokens::unrevoke_doc, + ), + ) .api_route( "/upstream-oauth-links", get_with( 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 ea149d517..89df0c416 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -7,10 +7,12 @@ mod add; mod get; mod list; mod revoke; +mod unrevoke; 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}, revoke::{doc as revoke_doc, handler as revoke}, + unrevoke::{doc as unrevoke_doc, handler as unrevoke}, }; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs new file mode 100644 index 000000000..53cbfcf95 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs @@ -0,0 +1,237 @@ +// 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::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserRegistrationToken}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Registration token with ID {0} not found")] + NotFound(Ulid), + + #[error("Registration token with ID {0} is not revoked")] + NotRevoked(Ulid), +} + +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, + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::NotRevoked(_) => StatusCode::BAD_REQUEST, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("unrevokeUserRegistrationToken") + .summary("Unrevoke a user registration token") + .description("Calling this endpoint will unrevoke a previously revoked user registration token, allowing it to be used for registrations again (subject to its usage limits and expiration).") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + // Get the valid token sample + let [valid_token, _] = UserRegistrationToken::samples(); + let id = valid_token.id(); + let response = SingleResponse::new(valid_token, format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke")); + t.description("Registration token was unrevoked").example(response) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotRevoked(Ulid::nil())); + t.description("Token is not revoked").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Registration token was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.unrevoke", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let token = repo + .user_registration_token() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Check if the token is not revoked + if token.revoked_at.is_none() { + return Err(RouteError::NotRevoked(id)); + } + + // Unrevoke the token using the repository method + let token = repo.user_registration_token().unrevoke(token).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + UserRegistrationToken::new(token, clock.now()), + format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke"), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unrevoke_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_token_456".to_owned(), + Some(5), + None, + ) + .await + .unwrap(); + + // Revoke it + let registration_token = repo + .user_registration_token() + .revoke(&state.clock, registration_token) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Now unrevoke it + let request = Request::post(format!( + "/api/admin/v1/user-registration-tokens/{}/unrevoke", + registration_token.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The revoked_at timestamp should be null + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_token_456", + "valid": true, + "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/unrevoke" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unrevoke_not_revoked_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_token_789".to_owned(), + None, + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Try to unrevoke a token that's not revoked + let request = Request::post(format!( + "/api/admin/v1/user-registration-tokens/{}/unrevoke", + registration_token.id + )) + .bearer(&token) + .empty(); + 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"], + format!( + "Registration token with ID {} is not revoked", + registration_token.id + ) + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_unrevoke_unknown_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/01040G2081040G2081040G2081/unrevoke", + ) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "Registration token with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/storage-pg/.sqlx/query-3c7fc3e386ce51187f6344ad65e1d78a7f026e8311bdc7d5ccc2f39d962e898f.json b/crates/storage-pg/.sqlx/query-3c7fc3e386ce51187f6344ad65e1d78a7f026e8311bdc7d5ccc2f39d962e898f.json new file mode 100644 index 000000000..8c2b2f4c1 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-3c7fc3e386ce51187f6344ad65e1d78a7f026e8311bdc7d5ccc2f39d962e898f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET revoked_at = NULL\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3c7fc3e386ce51187f6344ad65e1d78a7f026e8311bdc7d5ccc2f39d962e898f" +} diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs index 02b03038d..1f5a7231c 100644 --- a/crates/storage-pg/src/user/registration_token.rs +++ b/crates/storage-pg/src/user/registration_token.rs @@ -546,6 +546,38 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { Ok(token) } + + #[tracing::instrument( + name = "db.user_registration_token.unrevoke", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn unrevoke( + &mut self, + mut token: UserRegistrationToken, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registration_tokens + SET revoked_at = NULL + WHERE user_registration_token_id = $1 + "#, + Uuid::from(token.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + token.revoked_at = None; + + Ok(token) + } } #[cfg(test)] @@ -560,6 +592,51 @@ mod tests { use crate::PgRepository; + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_unrevoke(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a token + let token = repo + .user_registration_token() + .add(&mut rng, &clock, "test_token".to_owned(), None, None) + .await + .unwrap(); + + // Revoke the token + let revoked_token = repo + .user_registration_token() + .revoke(&clock, token) + .await + .unwrap(); + + // Verify it's revoked + assert!(revoked_token.revoked_at.is_some()); + + // Unrevoke the token + let unrevoked_token = repo + .user_registration_token() + .unrevoke(revoked_token) + .await + .unwrap(); + + // Verify it's no longer revoked + assert!(unrevoked_token.revoked_at.is_none()); + + // Check that we can find it with the non-revoked filter + let non_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false); + let page = repo + .user_registration_token() + .list(non_revoked_filter, Pagination::first(10)) + .await + .unwrap(); + + assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id)); + } + #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_list_and_count(pool: PgPool) { let mut rng = ChaChaRng::seed_from_u64(42); diff --git a/crates/storage/src/user/registration_token.rs b/crates/storage/src/user/registration_token.rs index 60f65a73f..196a2238b 100644 --- a/crates/storage/src/user/registration_token.rs +++ b/crates/storage/src/user/registration_token.rs @@ -196,6 +196,20 @@ pub trait UserRegistrationTokenRepository: Send + Sync { token: UserRegistrationToken, ) -> Result; + /// Unrevoke a previously revoked [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `token`: The [`UserRegistrationToken`] to unrevoke + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn unrevoke( + &mut self, + token: UserRegistrationToken, + ) -> Result; + /// List [`UserRegistrationToken`]s based on the provided filter /// /// Returns a list of matching [`UserRegistrationToken`]s @@ -249,6 +263,10 @@ repository_impl!(UserRegistrationTokenRepository: clock: &dyn Clock, token: UserRegistrationToken, ) -> Result; + async fn unrevoke( + &mut self, + token: UserRegistrationToken, + ) -> Result; async fn list( &mut self, filter: UserRegistrationTokenFilter, diff --git a/docs/api/spec.json b/docs/api/spec.json index 2fb0c3a85..ccea5a4c9 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2507,6 +2507,96 @@ } } }, + "/api/admin/v1/user-registration-tokens/{id}/unrevoke": { + "post": { + "tags": [ + "user-registration-token" + ], + "summary": "Unrevoke a user registration token", + "description": "Calling this endpoint will unrevoke a previously revoked user registration token, allowing it to be used for registrations again (subject to its usage limits and expiration).", + "operationId": "unrevokeUserRegistrationToken", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "Registration token was unrevoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "valid": true, + "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/unrevoke" + } + } + } + } + }, + "400": { + "description": "Token is not revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 is not revoked" + } + ] + } + } + } + }, + "404": { + "description": "Registration token was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/upstream-oauth-links": { "get": { "tags": [ From 52942ee94eaf6d722972c1fcabba621d9e98bf3e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 5 Jun 2025 15:53:52 +0200 Subject: [PATCH 2/3] Admin API to edit registration tokens --- crates/handlers/src/admin/v1/mod.rs | 4 + .../admin/v1/user_registration_tokens/mod.rs | 2 + .../v1/user_registration_tokens/update.rs | 511 ++++++++++++++++++ ...a991d2471667cf2981770447cde6fd025fbb7.json | 15 + ...61b07f43ab168956470d120166ed7eab631d9.json | 15 + .../storage-pg/src/user/registration_token.rs | 160 ++++++ crates/storage/src/user/registration_token.rs | 43 ++ docs/api/spec.json | 100 ++++ 8 files changed, 850 insertions(+) create mode 100644 crates/handlers/src/admin/v1/user_registration_tokens/update.rs create mode 100644 crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json create mode 100644 crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index be3f1922f..02586368b 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -136,6 +136,10 @@ where get_with( self::user_registration_tokens::get, self::user_registration_tokens::get_doc, + ) + .put_with( + self::user_registration_tokens::update, + self::user_registration_tokens::update_doc, ), ) .api_route( 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 89df0c416..3d61e10e6 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -8,6 +8,7 @@ mod get; mod list; mod revoke; mod unrevoke; +mod update; pub use self::{ add::{doc as add_doc, handler as add}, @@ -15,4 +16,5 @@ pub use self::{ list::{doc as list_doc, handler as list}, revoke::{doc as revoke_doc, handler as revoke}, unrevoke::{doc as unrevoke_doc, handler as unrevoke}, + update::{doc as update_doc, handler as update}, }; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs new file mode 100644 index 000000000..444c7ae6b --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs @@ -0,0 +1,511 @@ +// 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::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::{DateTime, Utc}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserRegistrationToken}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +// Any value that is present is considered Some value, including null. +fn deserialize_some<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + +/// # JSON payload for the `PUT /api/admin/v1/user-registration-tokens/{id}` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "EditUserRegistrationTokenRequest")] +pub struct Request { + /// New expiration date for the token, or null to remove expiration + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "deserialize_some" + )] + #[expect(clippy::option_option)] + expires_at: Option>>, + + /// New usage limit for the token, or null to remove the limit + #[expect(clippy::option_option)] + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "deserialize_some" + )] + usage_limit: Option>, +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Registration token with ID {0} not found")] + NotFound(Ulid), +} + +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, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("updateUserRegistrationToken") + .summary("Update a user registration token") + .description("Update properties of a user registration token such as expiration and usage limit. To set a field to null (removing the limit/expiration), include the field with a null value. To leave a field unchanged, omit it from the request body.") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + // Get the valid token sample + let [valid_token, _] = UserRegistrationToken::samples(); + let id = valid_token.id(); + let response = SingleResponse::new(valid_token, format!("/api/admin/v1/user-registration-tokens/{id}")); + t.description("Registration token was updated").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Registration token was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.update", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, + Json(request): Json, +) -> Result>, RouteError> { + let id = *id; + + // Get the token + let mut token = repo + .user_registration_token() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Update expiration if present in the request + if let Some(expires_at) = request.expires_at { + token = repo + .user_registration_token() + .set_expiry(token, expires_at) + .await?; + } + + // Update usage limit if present in the request + if let Some(usage_limit) = request.usage_limit { + token = repo + .user_registration_token() + .set_usage_limit(token, usage_limit) + .await?; + } + + repo.save().await?; + + Ok(Json(SingleResponse::new( + UserRegistrationToken::new(token, clock.now()), + format!("/api/admin/v1/user-registration-tokens/{id}"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_storage::Clock as _; + use serde_json::json; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_update_expiry(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token without expiry + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_expiry".to_owned(), + None, + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Update with an expiry date + let future_date = state.clock.now() + Duration::days(30); + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "expires_at": future_date + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify expiry was updated + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_expiry", + "valid": true, + "usage_limit": null, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-02-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + + // Now remove the expiry + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "expires_at": null + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify expiry was removed + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_expiry", + "valid": true, + "usage_limit": null, + "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_update_usage_limit(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token with usage limit + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_limit".to_owned(), + Some(5), + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Update the usage limit + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "usage_limit": 10 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify usage limit was updated + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_limit", + "valid": true, + "usage_limit": 10, + "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" + } + } + "#); + + // Now remove the usage limit + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "usage_limit": null + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify usage limit was removed + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_limit", + "valid": true, + "usage_limit": null, + "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_update_multiple_fields(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_multiple".to_owned(), + None, + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Update both fields + let future_date = state.clock.now() + Duration::days(30); + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "expires_at": future_date, + "usage_limit": 20 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Both fields were updated + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_multiple", + "valid": true, + "usage_limit": 20, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-02-15T14:40:00Z", + "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_update_no_fields(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_none".to_owned(), + Some(5), + Some(state.clock.now() + Duration::days(30)), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Send empty update + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({})); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // It shouldn't have updated the token + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_none", + "valid": true, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-02-15T14:40:00Z", + "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_update_unknown_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Try to update a non-existent token + let request = + Request::put("/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081") + .bearer(&token) + .json(json!({ + "usage_limit": 5 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + + assert_eq!( + body["errors"][0]["title"], + "Registration token with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json b/crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json new file mode 100644 index 000000000..3b3f65b29 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET expires_at = $2\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7" +} diff --git a/crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json b/crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json new file mode 100644 index 000000000..275a08952 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET usage_limit = $2\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9" +} diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs index 1f5a7231c..f7a9ab54e 100644 --- a/crates/storage-pg/src/user/registration_token.rs +++ b/crates/storage-pg/src/user/registration_token.rs @@ -578,6 +578,79 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { Ok(token) } + + #[tracing::instrument( + name = "db.user_registration_token.set_expiry", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn set_expiry( + &mut self, + mut token: UserRegistrationToken, + expires_at: Option>, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registration_tokens + SET expires_at = $2 + WHERE user_registration_token_id = $1 + "#, + Uuid::from(token.id), + expires_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + token.expires_at = expires_at; + + Ok(token) + } + + #[tracing::instrument( + name = "db.user_registration_token.set_usage_limit", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn set_usage_limit( + &mut self, + mut token: UserRegistrationToken, + usage_limit: Option, + ) -> Result { + let usage_limit_i32 = usage_limit + .map(i32::try_from) + .transpose() + .map_err(DatabaseError::to_invalid_operation)?; + + let res = sqlx::query!( + r#" + UPDATE user_registration_tokens + SET usage_limit = $2 + WHERE user_registration_token_id = $1 + "#, + Uuid::from(token.id), + usage_limit_i32, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + token.usage_limit = usage_limit; + + Ok(token) + } } #[cfg(test)] @@ -637,6 +710,93 @@ mod tests { assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id)); } + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_expiry(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a token without expiry + let token = repo + .user_registration_token() + .add(&mut rng, &clock, "test_token_expiry".to_owned(), None, None) + .await + .unwrap(); + + // Verify it has no expiration + assert!(token.expires_at.is_none()); + + // Set an expiration + let future_time = clock.now() + Duration::days(30); + let updated_token = repo + .user_registration_token() + .set_expiry(token, Some(future_time)) + .await + .unwrap(); + + // Verify expiration is set + assert_eq!(updated_token.expires_at, Some(future_time)); + + // Remove the expiration + let final_token = repo + .user_registration_token() + .set_expiry(updated_token, None) + .await + .unwrap(); + + // Verify expiration is removed + assert!(final_token.expires_at.is_none()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_usage_limit(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a token without usage limit + let token = repo + .user_registration_token() + .add(&mut rng, &clock, "test_token_limit".to_owned(), None, None) + .await + .unwrap(); + + // Verify it has no usage limit + assert!(token.usage_limit.is_none()); + + // Set a usage limit + let updated_token = repo + .user_registration_token() + .set_usage_limit(token, Some(5)) + .await + .unwrap(); + + // Verify usage limit is set + assert_eq!(updated_token.usage_limit, Some(5)); + + // Change the usage limit + let changed_token = repo + .user_registration_token() + .set_usage_limit(updated_token, Some(10)) + .await + .unwrap(); + + // Verify usage limit is changed + assert_eq!(changed_token.usage_limit, Some(10)); + + // Remove the usage limit + let final_token = repo + .user_registration_token() + .set_usage_limit(changed_token, None) + .await + .unwrap(); + + // Verify usage limit is removed + assert!(final_token.usage_limit.is_none()); + } + #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_list_and_count(pool: PgPool) { let mut rng = ChaChaRng::seed_from_u64(42); diff --git a/crates/storage/src/user/registration_token.rs b/crates/storage/src/user/registration_token.rs index 196a2238b..e3913b5d8 100644 --- a/crates/storage/src/user/registration_token.rs +++ b/crates/storage/src/user/registration_token.rs @@ -210,6 +210,39 @@ pub trait UserRegistrationTokenRepository: Send + Sync { token: UserRegistrationToken, ) -> Result; + /// Set the expiration time of a [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `token`: The [`UserRegistrationToken`] to update + /// * `expires_at`: The new expiration time, or `None` to remove the + /// expiration + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn set_expiry( + &mut self, + token: UserRegistrationToken, + expires_at: Option>, + ) -> Result; + + /// Set the usage limit of a [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `token`: The [`UserRegistrationToken`] to update + /// * `usage_limit`: The new usage limit, or `None` to remove the limit + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn set_usage_limit( + &mut self, + token: UserRegistrationToken, + usage_limit: Option, + ) -> Result; + /// List [`UserRegistrationToken`]s based on the provided filter /// /// Returns a list of matching [`UserRegistrationToken`]s @@ -267,6 +300,16 @@ repository_impl!(UserRegistrationTokenRepository: &mut self, token: UserRegistrationToken, ) -> Result; + async fn set_expiry( + &mut self, + token: UserRegistrationToken, + expires_at: Option>, + ) -> Result; + async fn set_usage_limit( + &mut self, + token: UserRegistrationToken, + usage_limit: Option, + ) -> Result; async fn list( &mut self, filter: UserRegistrationTokenFilter, diff --git a/docs/api/spec.json b/docs/api/spec.json index ccea5a4c9..0082ea37c 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2415,6 +2415,87 @@ } } } + }, + "put": { + "tags": [ + "user-registration-token" + ], + "summary": "Update a user registration token", + "description": "Update properties of a user registration token such as expiration and usage limit. To set a field to null (removing the limit/expiration), include the field with a null value. To leave a field unchanged, omit it from the request body.", + "operationId": "updateUserRegistrationToken", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditUserRegistrationTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Registration token was updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "valid": true, + "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" + } + } + } + } + }, + "404": { + "description": "Registration token was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } } }, "/api/admin/v1/user-registration-tokens/{id}/revoke": { @@ -4228,6 +4309,25 @@ } } }, + "EditUserRegistrationTokenRequest": { + "title": "JSON payload for the `PUT /api/admin/v1/user-registration-tokens/{id}` endpoint", + "type": "object", + "properties": { + "expires_at": { + "description": "New expiration date for the token, or null to remove expiration", + "type": "string", + "format": "date-time", + "nullable": true + }, + "usage_limit": { + "description": "New usage limit for the token, or null to remove the limit", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + } + } + }, "UpstreamOAuthLinkFilter": { "type": "object", "properties": { From 989c97ae0281578e0f71203b100804a55a85308b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 5 Jun 2025 17:04:04 +0200 Subject: [PATCH 3/3] Better error message when trying to create a registration token that already exists --- .../admin/v1/user_registration_tokens/add.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs index fdb958f58..afa409a67 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs @@ -25,6 +25,9 @@ use crate::{ #[derive(Debug, thiserror::Error, OperationIo)] #[aide(output_with = "Json")] pub enum RouteError { + #[error("A registration token with the same token already exists")] + Conflict(mas_data_model::UserRegistrationToken), + #[error(transparent)] Internal(Box), } @@ -36,6 +39,7 @@ impl IntoResponse for RouteError { let error = ErrorResponse::from_error(&self); let sentry_event_id = record_error!(self, Self::Internal(_)); let status = match self { + Self::Conflict(_) => StatusCode::CONFLICT, Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; (status, sentry_event_id, Json(error)).into_response() @@ -83,6 +87,12 @@ pub async fn handler( .token .unwrap_or_else(|| Alphanumeric.sample_string(&mut rng, 12)); + // See if we have an existing token with the same token + let existing_token = repo.user_registration_token().find_by_token(&token).await?; + if let Some(existing_token) = existing_token { + return Err(RouteError::Conflict(existing_token)); + } + let registration_token = repo .user_registration_token() .add( @@ -196,4 +206,56 @@ mod tests { } "#); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_conflict(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", + "valid": true, + "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" + } + } + "#); + + 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::CONFLICT); + } }