diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index f0187e246..6890848ac 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -73,6 +73,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { description: Some("Manage browser sessions of users".to_owned()), ..Tag::default() }) + .tag(Tag { + name: "user-registration-token".to_owned(), + description: Some("Manage user registration tokens".to_owned()), + ..Tag::default() + }) .tag(Tag { name: "upstream-oauth-link".to_owned(), description: Some( diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 31478d47e..1dbf12b9b 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -602,3 +602,83 @@ impl PolicyData { }] } } + +/// A registration token +#[derive(Serialize, JsonSchema)] +pub struct UserRegistrationToken { + #[serde(skip)] + id: Ulid, + + /// The token string + token: String, + + /// Maximum number of times this token can be used + usage_limit: Option, + + /// Number of times this token has been used + times_used: u32, + + /// When the token was created + created_at: DateTime, + + /// When the token was last used. If null, the token has never been used. + last_used_at: Option>, + + /// When the token expires. If null, the token never expires. + expires_at: Option>, + + /// When the token was revoked. If null, the token is not revoked. + revoked_at: Option>, +} + +impl From for UserRegistrationToken { + fn from(token: mas_data_model::UserRegistrationToken) -> Self { + Self { + id: token.id, + token: token.token, + usage_limit: token.usage_limit, + times_used: token.times_used, + created_at: token.created_at, + last_used_at: token.last_used_at, + expires_at: token.expires_at, + revoked_at: token.revoked_at, + } + } +} + +impl Resource for UserRegistrationToken { + const KIND: &'static str = "user-registration_token"; + const PATH: &'static str = "/api/admin/v1/user-registration-tokens"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl UserRegistrationToken { + /// Samples of registration tokens + pub fn samples() -> [Self; 2] { + [ + Self { + id: Ulid::from_bytes([0x01; 16]), + token: "abc123def456".to_owned(), + usage_limit: Some(10), + times_used: 5, + created_at: DateTime::default(), + last_used_at: Some(DateTime::default()), + expires_at: Some(DateTime::default() + chrono::Duration::days(30)), + revoked_at: None, + }, + Self { + id: Ulid::from_bytes([0x02; 16]), + token: "xyz789abc012".to_owned(), + usage_limit: None, + times_used: 0, + created_at: DateTime::default(), + last_used_at: None, + expires_at: None, + revoked_at: Some(DateTime::default()), + }, + ] + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 789bb6759..b1154ce92 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -23,6 +23,7 @@ mod oauth2_sessions; mod policy_data; mod upstream_oauth_links; mod user_emails; +mod user_registration_tokens; mod user_sessions; mod users; @@ -119,6 +120,13 @@ where "/user-sessions/{id}", get_with(self::user_sessions::get, self::user_sessions::get_doc), ) + .api_route( + "/user-registration-tokens", + get_with( + self::user_registration_tokens::list, + self::user_registration_tokens::list_doc, + ), + ) .api_route( "/upstream-oauth-links", get_with( diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs new file mode 100644 index 000000000..1052dfea6 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs @@ -0,0 +1,1142 @@ +// 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, + extract::{Query, rejection::QueryRejection}, + response::IntoResponse, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_storage::{Page, user::UserRegistrationTokenFilter}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserRegistrationToken}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "RegistrationTokenFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve tokens that have (or have not) been used at least once + #[serde(rename = "filter[used]")] + used: Option, + + /// Retrieve tokens that are (or are not) revoked + #[serde(rename = "filter[revoked]")] + revoked: Option, + + /// Retrieve tokens that are (or are not) expired + #[serde(rename = "filter[expired]")] + expired: Option, + + /// Retrieve tokens that are (or are not) valid + /// + /// Valid means that the token has not expired, is not revoked, and has not + /// reached its usage limit. + #[serde(rename = "filter[valid]")] + valid: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(used) = self.used { + write!(f, "{sep}filter[used]={used}")?; + sep = '&'; + } + if let Some(revoked) = self.revoked { + write!(f, "{sep}filter[revoked]={revoked}")?; + sep = '&'; + } + if let Some(expired) = self.expired { + write!(f, "{sep}filter[expired]={expired}")?; + sep = '&'; + } + if let Some(valid) = self.valid { + write!(f, "{sep}filter[valid]={valid}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +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::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listUserRegistrationTokens") + .summary("List user registration tokens") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + let tokens = UserRegistrationToken::samples(); + let pagination = mas_storage::Pagination::first(tokens.len()); + let page = Page { + edges: tokens.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of registration tokens") + .example(PaginatedResponse::new( + page, + pagination, + 42, + UserRegistrationToken::PATH, + )) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.registration_tokens.list", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UserRegistrationToken::PATH); + let mut filter = UserRegistrationTokenFilter::new(clock.now()); + + if let Some(used) = params.used { + filter = filter.with_been_used(used); + } + + if let Some(revoked) = params.revoked { + filter = filter.with_revoked(revoked); + } + + if let Some(expired) = params.expired { + filter = filter.with_expired(expired); + } + + if let Some(valid) = params.valid { + filter = filter.with_valid(valid); + } + + let page = repo + .user_registration_token() + .list(filter, pagination) + .await?; + let count = repo.user_registration_token().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(UserRegistrationToken::from), + pagination, + count, + &base, + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_storage::Clock as _; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + async fn create_test_tokens(state: &mut TestState) { + let mut repo = state.repository().await.unwrap(); + + // Token 1: Never used, not revoked + repo.user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_unused".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + + // Token 2: Used, not revoked + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_used".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + repo.user_registration_token() + .use_token(&state.clock, token) + .await + .unwrap(); + + // Token 3: Never used, revoked + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_revoked".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + repo.user_registration_token() + .revoke(&state.clock, token) + .await + .unwrap(); + + // Token 4: Used, revoked + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_used_revoked".to_owned(), + Some(10), + None, + ) + .await + .unwrap(); + let token = repo + .user_registration_token() + .use_token(&state.clock, token) + .await + .unwrap(); + repo.user_registration_token() + .revoke(&state.clock, token) + .await + .unwrap(); + + // Token 5: Expired token + let expires_at = state.clock.now() - Duration::try_days(1).unwrap(); + repo.user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "token_expired".to_owned(), + Some(5), + Some(expires_at), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list_all_tokens(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + let request = Request::get("/api/admin/v1/user-registration-tokens") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "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" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_used(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for used tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[used]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[used]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[used]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[used]=true&page[last]=10" + } + } + "#); + + // Filter for unused tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[used]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "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?filter[used]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[used]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[used]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_revoked(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for revoked tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[revoked]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[revoked]=true&page[last]=10" + } + } + "#); + + // Filter for non-revoked tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[revoked]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "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?filter[revoked]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[revoked]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[revoked]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_expired(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for expired tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[expired]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[expired]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[expired]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[expired]=true&page[last]=10" + } + } + "#); + + // Filter for non-expired tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[expired]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 4 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "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" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[expired]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[expired]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[expired]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_filter_by_valid(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for valid tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[valid]=true") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "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?filter[valid]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[valid]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[valid]=true&page[last]=10" + } + } + "#); + + // Filter for invalid tokens + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[valid]=false") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 3 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[valid]=false&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[valid]=false&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[valid]=false&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_combined_filters(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Filter for used AND revoked tokens + let request = Request::get( + "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true", + ) + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true&page[first]=10", + "first": "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true&page[first]=10", + "last": "/api/admin/v1/user-registration-tokens?filter[used]=true&filter[revoked]=true&page[last]=10" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_pagination(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + create_test_tokens(&mut state).await; + + // Request with pagination (2 per page) + let request = Request::get("/api/admin/v1/user-registration-tokens?page[first]=2") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG064K8BYZXSY5G511Z", + "attributes": { + "token": "token_expired", + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-01-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG07HNEZXNQM2KNBNF6", + "attributes": { + "token": "token_used", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[first]=2", + "first": "/api/admin/v1/user-registration-tokens?page[first]=2", + "last": "/api/admin/v1/user-registration-tokens?page[last]=2", + "next": "/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG07HNEZXNQM2KNBNF6&page[first]=2" + } + } + "#); + + // Request second page + let request = Request::get("/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG07HNEZXNQM2KNBNF6&page[first]=2") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG09AVTNSQFMSR34AJC", + "attributes": { + "token": "token_revoked", + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" + } + }, + { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "token_unused", + "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?page[after]=01FSHN9AG07HNEZXNQM2KNBNF6&page[first]=2", + "first": "/api/admin/v1/user-registration-tokens?page[first]=2", + "last": "/api/admin/v1/user-registration-tokens?page[last]=2", + "next": "/api/admin/v1/user-registration-tokens?page[after]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=2" + } + } + "#); + + // Request last item + let request = Request::get("/api/admin/v1/user-registration-tokens?page[last]=1") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r#" + { + "meta": { + "count": 5 + }, + "data": [ + { + "type": "user-registration_token", + "id": "01FSHN9AG0S3ZJD8CXQ7F11KXN", + "attributes": { + "token": "token_used_revoked", + "usage_limit": 10, + "times_used": 1, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": "2022-01-16T14:40:00Z", + "expires_at": null, + "revoked_at": "2022-01-16T14:40:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[last]=1", + "first": "/api/admin/v1/user-registration-tokens?page[first]=1", + "last": "/api/admin/v1/user-registration-tokens?page[last]=1", + "prev": "/api/admin/v1/user-registration-tokens?page[before]=01FSHN9AG0S3ZJD8CXQ7F11KXN&page[last]=1" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invalid_filter(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let admin_token = state.token_with_scope("urn:mas:admin").await; + + // Try with invalid filter value + let request = Request::get("/api/admin/v1/user-registration-tokens?filter[used]=invalid") + .bearer(&admin_token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::BAD_REQUEST); + + let body: serde_json::Value = response.json(); + assert!( + body["errors"][0]["title"] + .as_str() + .unwrap() + .contains("Invalid filter parameters") + ); + } +} diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs new file mode 100644 index 000000000..e1bd34314 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -0,0 +1,8 @@ +// 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. + +mod list; + +pub use self::list::{doc as list_doc, handler as list}; diff --git a/docs/api/spec.json b/docs/api/spec.json index 6e90e231f..138264bb4 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2132,6 +2132,166 @@ } } }, + "/api/admin/v1/user-registration-tokens": { + "get": { + "tags": [ + "user-registration-token" + ], + "summary": "List user registration tokens", + "operationId": "listUserRegistrationTokens", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[used]", + "description": "Retrieve tokens that have (or have not) been used at least once", + "schema": { + "description": "Retrieve tokens that have (or have not) been used at least once", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[revoked]", + "description": "Retrieve tokens that are (or are not) revoked", + "schema": { + "description": "Retrieve tokens that are (or are not) revoked", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[expired]", + "description": "Retrieve tokens that are (or are not) expired", + "schema": { + "description": "Retrieve tokens that are (or are not) expired", + "type": "boolean", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[valid]", + "description": "Retrieve tokens that are (or are not) valid\n\nValid means that the token has not expired, is not revoked, and has not reached its usage limit.", + "schema": { + "description": "Retrieve tokens that are (or are not) valid\n\nValid means that the token has not expired, is not revoked, and has not reached its usage limit.", + "type": "boolean", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of registration tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_UserRegistrationToken" + }, + "example": { + "meta": { + "count": 42 + }, + "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" + } + }, + { + "type": "user-registration_token", + "id": "02081040G2081040G2081040G2", + "attributes": { + "token": "xyz789abc012", + "usage_limit": null, + "times_used": 0, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": "1970-01-01T00:00:00Z" + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" + } + } + ], + "links": { + "self": "/api/admin/v1/user-registration-tokens?page[first]=2", + "first": "/api/admin/v1/user-registration-tokens?page[first]=2", + "last": "/api/admin/v1/user-registration-tokens?page[last]=2", + "next": "/api/admin/v1/user-registration-tokens?page[after]=02081040G2081040G2081040G2&page[first]=2" + } + } + } + } + } + } + } + }, "/api/admin/v1/upstream-oauth-links": { "get": { "tags": [ @@ -3588,6 +3748,136 @@ } } }, + "RegistrationTokenFilter": { + "type": "object", + "properties": { + "filter[used]": { + "description": "Retrieve tokens that have (or have not) been used at least once", + "type": "boolean", + "nullable": true + }, + "filter[revoked]": { + "description": "Retrieve tokens that are (or are not) revoked", + "type": "boolean", + "nullable": true + }, + "filter[expired]": { + "description": "Retrieve tokens that are (or are not) expired", + "type": "boolean", + "nullable": true + }, + "filter[valid]": { + "description": "Retrieve tokens that are (or are not) valid\n\nValid means that the token has not expired, is not revoked, and has not reached its usage limit.", + "type": "boolean", + "nullable": true + } + } + }, + "PaginatedResponse_for_UserRegistrationToken": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_UserRegistrationToken" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_UserRegistrationToken": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/UserRegistrationToken" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "UserRegistrationToken": { + "description": "A registration token", + "type": "object", + "required": [ + "created_at", + "times_used", + "token" + ], + "properties": { + "token": { + "description": "The token string", + "type": "string" + }, + "usage_limit": { + "description": "Maximum number of times this token can be used", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + }, + "times_used": { + "description": "Number of times this token has been used", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "created_at": { + "description": "When the token was created", + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "description": "When the token was last used. If null, the token has never been used.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "expires_at": { + "description": "When the token expires. If null, the token never expires.", + "type": "string", + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "description": "When the token was revoked. If null, the token is not revoked.", + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, "UpstreamOAuthLinkFilter": { "type": "object", "properties": { @@ -3779,6 +4069,10 @@ "name": "user-session", "description": "Manage browser sessions of users" }, + { + "name": "user-registration-token", + "description": "Manage user registration tokens" + }, { "name": "upstream-oauth-link", "description": "Manage links between local users and identities from upstream OAuth 2.0 providers"