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": [