Skip to content

Commit c8f2a21

Browse files
committed
Admin API to un-revoke a user registration token.
1 parent c595a0d commit c8f2a21

File tree

7 files changed

+445
-0
lines changed

7 files changed

+445
-0
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ where
145145
self::user_registration_tokens::revoke_doc,
146146
),
147147
)
148+
.api_route(
149+
"/user-registration-tokens/{id}/unrevoke",
150+
post_with(
151+
self::user_registration_tokens::unrevoke,
152+
self::user_registration_tokens::unrevoke_doc,
153+
),
154+
)
148155
.api_route(
149156
"/upstream-oauth-links",
150157
get_with(

crates/handlers/src/admin/v1/user_registration_tokens/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ mod add;
77
mod get;
88
mod list;
99
mod revoke;
10+
mod unrevoke;
1011

1112
pub use self::{
1213
add::{doc as add_doc, handler as add},
1314
get::{doc as get_doc, handler as get},
1415
list::{doc as list_doc, handler as list},
1516
revoke::{doc as revoke_doc, handler as revoke},
17+
unrevoke::{doc as unrevoke_doc, handler as unrevoke},
1618
};
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Copyright 2025 The Matrix.org Foundation C.I.C.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use aide::{OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use hyper::StatusCode;
9+
use mas_axum_utils::record_error;
10+
use ulid::Ulid;
11+
12+
use crate::{
13+
admin::{
14+
call_context::CallContext,
15+
model::{Resource, UserRegistrationToken},
16+
params::UlidPathParam,
17+
response::{ErrorResponse, SingleResponse},
18+
},
19+
impl_from_error_for_route,
20+
};
21+
22+
#[derive(Debug, thiserror::Error, OperationIo)]
23+
#[aide(output_with = "Json<ErrorResponse>")]
24+
pub enum RouteError {
25+
#[error(transparent)]
26+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
27+
28+
#[error("Registration token with ID {0} not found")]
29+
NotFound(Ulid),
30+
31+
#[error("Registration token with ID {0} is not revoked")]
32+
NotRevoked(Ulid),
33+
}
34+
35+
impl_from_error_for_route!(mas_storage::RepositoryError);
36+
37+
impl IntoResponse for RouteError {
38+
fn into_response(self) -> axum::response::Response {
39+
let error = ErrorResponse::from_error(&self);
40+
let sentry_event_id = record_error!(self, Self::Internal(_));
41+
let status = match self {
42+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
43+
Self::NotFound(_) => StatusCode::NOT_FOUND,
44+
Self::NotRevoked(_) => StatusCode::BAD_REQUEST,
45+
};
46+
(status, sentry_event_id, Json(error)).into_response()
47+
}
48+
}
49+
50+
pub fn doc(operation: TransformOperation) -> TransformOperation {
51+
operation
52+
.id("unrevokeUserRegistrationToken")
53+
.summary("Unrevoke a user registration token")
54+
.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).")
55+
.tag("user-registration-token")
56+
.response_with::<200, Json<SingleResponse<UserRegistrationToken>>, _>(|t| {
57+
// Get the valid token sample
58+
let [valid_token, _] = UserRegistrationToken::samples();
59+
let id = valid_token.id();
60+
let response = SingleResponse::new(valid_token, format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke"));
61+
t.description("Registration token was unrevoked").example(response)
62+
})
63+
.response_with::<400, RouteError, _>(|t| {
64+
let response = ErrorResponse::from_error(&RouteError::NotRevoked(Ulid::nil()));
65+
t.description("Token is not revoked").example(response)
66+
})
67+
.response_with::<404, RouteError, _>(|t| {
68+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
69+
t.description("Registration token was not found").example(response)
70+
})
71+
}
72+
73+
#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.unrevoke", skip_all)]
74+
pub async fn handler(
75+
CallContext {
76+
mut repo, clock, ..
77+
}: CallContext,
78+
id: UlidPathParam,
79+
) -> Result<Json<SingleResponse<UserRegistrationToken>>, RouteError> {
80+
let id = *id;
81+
let token = repo
82+
.user_registration_token()
83+
.lookup(id)
84+
.await?
85+
.ok_or(RouteError::NotFound(id))?;
86+
87+
// Check if the token is not revoked
88+
if token.revoked_at.is_none() {
89+
return Err(RouteError::NotRevoked(id));
90+
}
91+
92+
// Unrevoke the token using the repository method
93+
let token = repo.user_registration_token().unrevoke(token).await?;
94+
95+
repo.save().await?;
96+
97+
Ok(Json(SingleResponse::new(
98+
UserRegistrationToken::new(token, clock.now()),
99+
format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke"),
100+
)))
101+
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use hyper::{Request, StatusCode};
106+
use sqlx::PgPool;
107+
108+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
109+
110+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
111+
async fn test_unrevoke_token(pool: PgPool) {
112+
setup();
113+
let mut state = TestState::from_pool(pool).await.unwrap();
114+
let token = state.token_with_scope("urn:mas:admin").await;
115+
116+
let mut repo = state.repository().await.unwrap();
117+
118+
// Create a token
119+
let registration_token = repo
120+
.user_registration_token()
121+
.add(
122+
&mut state.rng(),
123+
&state.clock,
124+
"test_token_456".to_owned(),
125+
Some(5),
126+
None,
127+
)
128+
.await
129+
.unwrap();
130+
131+
// Revoke it
132+
let registration_token = repo
133+
.user_registration_token()
134+
.revoke(&state.clock, registration_token)
135+
.await
136+
.unwrap();
137+
138+
repo.save().await.unwrap();
139+
140+
// Now unrevoke it
141+
let request = Request::post(format!(
142+
"/api/admin/v1/user-registration-tokens/{}/unrevoke",
143+
registration_token.id
144+
))
145+
.bearer(&token)
146+
.empty();
147+
let response = state.request(request).await;
148+
response.assert_status(StatusCode::OK);
149+
let body: serde_json::Value = response.json();
150+
151+
// The revoked_at timestamp should be null
152+
insta::assert_json_snapshot!(body, @r#"
153+
{
154+
"data": {
155+
"type": "user-registration_token",
156+
"id": "01FSHN9AG0MZAA6S4AF7CTV32E",
157+
"attributes": {
158+
"token": "test_token_456",
159+
"valid": true,
160+
"usage_limit": 5,
161+
"times_used": 0,
162+
"created_at": "2022-01-16T14:40:00Z",
163+
"last_used_at": null,
164+
"expires_at": null,
165+
"revoked_at": null
166+
},
167+
"links": {
168+
"self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E"
169+
}
170+
},
171+
"links": {
172+
"self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E/unrevoke"
173+
}
174+
}
175+
"#);
176+
}
177+
178+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
179+
async fn test_unrevoke_not_revoked_token(pool: PgPool) {
180+
setup();
181+
let mut state = TestState::from_pool(pool).await.unwrap();
182+
let token = state.token_with_scope("urn:mas:admin").await;
183+
184+
let mut repo = state.repository().await.unwrap();
185+
let registration_token = repo
186+
.user_registration_token()
187+
.add(
188+
&mut state.rng(),
189+
&state.clock,
190+
"test_token_789".to_owned(),
191+
None,
192+
None,
193+
)
194+
.await
195+
.unwrap();
196+
197+
repo.save().await.unwrap();
198+
199+
// Try to unrevoke a token that's not revoked
200+
let request = Request::post(format!(
201+
"/api/admin/v1/user-registration-tokens/{}/unrevoke",
202+
registration_token.id
203+
))
204+
.bearer(&token)
205+
.empty();
206+
let response = state.request(request).await;
207+
response.assert_status(StatusCode::BAD_REQUEST);
208+
let body: serde_json::Value = response.json();
209+
assert_eq!(
210+
body["errors"][0]["title"],
211+
format!(
212+
"Registration token with ID {} is not revoked",
213+
registration_token.id
214+
)
215+
);
216+
}
217+
218+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
219+
async fn test_unrevoke_unknown_token(pool: PgPool) {
220+
setup();
221+
let mut state = TestState::from_pool(pool).await.unwrap();
222+
let token = state.token_with_scope("urn:mas:admin").await;
223+
224+
let request = Request::post(
225+
"/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081/unrevoke",
226+
)
227+
.bearer(&token)
228+
.empty();
229+
let response = state.request(request).await;
230+
response.assert_status(StatusCode::NOT_FOUND);
231+
let body: serde_json::Value = response.json();
232+
assert_eq!(
233+
body["errors"][0]["title"],
234+
"Registration token with ID 01040G2081040G2081040G2081 not found"
235+
);
236+
}
237+
}

crates/storage-pg/.sqlx/query-3c7fc3e386ce51187f6344ad65e1d78a7f026e8311bdc7d5ccc2f39d962e898f.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/src/user/registration_token.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,38 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
546546

547547
Ok(token)
548548
}
549+
550+
#[tracing::instrument(
551+
name = "db.user_registration_token.unrevoke",
552+
skip_all,
553+
fields(
554+
db.query.text,
555+
user_registration_token.id = %token.id,
556+
),
557+
err,
558+
)]
559+
async fn unrevoke(
560+
&mut self,
561+
mut token: UserRegistrationToken,
562+
) -> Result<UserRegistrationToken, Self::Error> {
563+
let res = sqlx::query!(
564+
r#"
565+
UPDATE user_registration_tokens
566+
SET revoked_at = NULL
567+
WHERE user_registration_token_id = $1
568+
"#,
569+
Uuid::from(token.id),
570+
)
571+
.traced()
572+
.execute(&mut *self.conn)
573+
.await?;
574+
575+
DatabaseError::ensure_affected_rows(&res, 1)?;
576+
577+
token.revoked_at = None;
578+
579+
Ok(token)
580+
}
549581
}
550582

551583
#[cfg(test)]
@@ -560,6 +592,51 @@ mod tests {
560592

561593
use crate::PgRepository;
562594

595+
#[sqlx::test(migrator = "crate::MIGRATOR")]
596+
async fn test_unrevoke(pool: PgPool) {
597+
let mut rng = ChaChaRng::seed_from_u64(42);
598+
let clock = MockClock::default();
599+
600+
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
601+
602+
// Create a token
603+
let token = repo
604+
.user_registration_token()
605+
.add(&mut rng, &clock, "test_token".to_owned(), None, None)
606+
.await
607+
.unwrap();
608+
609+
// Revoke the token
610+
let revoked_token = repo
611+
.user_registration_token()
612+
.revoke(&clock, token)
613+
.await
614+
.unwrap();
615+
616+
// Verify it's revoked
617+
assert!(revoked_token.revoked_at.is_some());
618+
619+
// Unrevoke the token
620+
let unrevoked_token = repo
621+
.user_registration_token()
622+
.unrevoke(revoked_token)
623+
.await
624+
.unwrap();
625+
626+
// Verify it's no longer revoked
627+
assert!(unrevoked_token.revoked_at.is_none());
628+
629+
// Check that we can find it with the non-revoked filter
630+
let non_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
631+
let page = repo
632+
.user_registration_token()
633+
.list(non_revoked_filter, Pagination::first(10))
634+
.await
635+
.unwrap();
636+
637+
assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id));
638+
}
639+
563640
#[sqlx::test(migrator = "crate::MIGRATOR")]
564641
async fn test_list_and_count(pool: PgPool) {
565642
let mut rng = ChaChaRng::seed_from_u64(42);

0 commit comments

Comments
 (0)