Add username and email columns to registration tokens

A registration token can now optionally carry the username and/or the email
address the account it is used for has to be registered with. This only adds
the columns and plumbs them through the storage layer and the CLI; nothing
enforces them yet.
This commit is contained in:
Quentin Gliech
2026-10-13 15:30:54 +02:00
parent 14ba102309
commit d765af146d
18 changed files with 358 additions and 30 deletions
+9 -1
View File
@@ -510,7 +510,15 @@ impl Options {
// Create the token
let registration_token = repo
.user_registration_token()
.add(&mut rng, &clock, token_str, usage_limit, expires_at)
.add(
&mut rng,
&clock,
token_str,
usage_limit,
expires_at,
None,
None,
)
.await?;
repo.into_inner().commit().await?;
+2
View File
@@ -230,6 +230,8 @@ pub struct UserRegistrationPassword {
pub struct UserRegistrationToken {
pub id: Ulid,
pub token: String,
pub username: Option<String>,
pub email: Option<String>,
pub usage_limit: Option<u32>,
pub times_used: u32,
pub created_at: DateTime<Utc>,
@@ -28,7 +28,7 @@ use crate::{
#[aide(output_with = "Json<ErrorResponse>")]
pub enum RouteError {
#[error("A registration token with the same token already exists")]
Conflict(mas_data_model::UserRegistrationToken),
Conflict(Box<mas_data_model::UserRegistrationToken>),
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
@@ -61,6 +61,14 @@ pub struct Request {
/// When the token expires. If not provided, the token never expires.
expires_at: Option<DateTime<Utc>>,
/// A username to impose on the registering user. If set, the user cannot
/// choose their own username.
username: Option<String>,
/// An email address to impose on the registering user. If set, the user
/// cannot choose their own email.
email: Option<String>,
}
pub fn doc(operation: TransformOperation) -> TransformOperation {
@@ -92,7 +100,7 @@ pub async fn handler(
// 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));
return Err(RouteError::Conflict(Box::new(existing_token)));
}
let registration_token = repo
@@ -103,6 +111,8 @@ pub async fn handler(
token,
params.usage_limit,
params.expires_at,
params.username,
params.email,
)
.await?;
@@ -104,6 +104,8 @@ mod tests {
"test_token_123".to_owned(),
Some(5),
None,
None,
None,
)
.await
.unwrap();
@@ -206,6 +206,8 @@ mod tests {
"token_unused".to_owned(),
Some(10),
None,
None,
None,
)
.await
.unwrap();
@@ -219,6 +221,8 @@ mod tests {
"token_used".to_owned(),
Some(10),
None,
None,
None,
)
.await
.unwrap();
@@ -236,6 +240,8 @@ mod tests {
"token_revoked".to_owned(),
Some(10),
None,
None,
None,
)
.await
.unwrap();
@@ -253,6 +259,8 @@ mod tests {
"token_used_revoked".to_owned(),
Some(10),
None,
None,
None,
)
.await
.unwrap();
@@ -275,6 +283,8 @@ mod tests {
"token_expired".to_owned(),
Some(5),
Some(expires_at),
None,
None,
)
.await
.unwrap();
@@ -125,6 +125,8 @@ mod tests {
"test_token_456".to_owned(),
Some(5),
None,
None,
None,
)
.await
.unwrap();
@@ -162,6 +164,8 @@ mod tests {
"test_token_789".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -125,6 +125,8 @@ mod tests {
"test_token_456".to_owned(),
Some(5),
None,
None,
None,
)
.await
.unwrap();
@@ -191,6 +193,8 @@ mod tests {
"test_token_789".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -166,6 +166,8 @@ mod tests {
"test_update_expiry".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -271,6 +273,8 @@ mod tests {
"test_update_limit".to_owned(),
Some(5),
None,
None,
None,
)
.await
.unwrap();
@@ -375,6 +379,8 @@ mod tests {
"test_update_multiple".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -441,6 +447,8 @@ mod tests {
"test_update_none".to_owned(),
Some(5),
Some(state.clock.now() + Duration::days(30)),
None,
None,
)
.await
.unwrap();
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_registration_token_id,\n token,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE token = $1\n ",
"query": "\n SELECT user_registration_token_id,\n token,\n username,\n email,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE token = $1\n ",
"describe": {
"columns": [
{
@@ -15,31 +15,41 @@
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "usage_limit",
"type_info": "Int4"
},
{
"ordinal": 3,
"ordinal": 5,
"name": "times_used",
"type_info": "Int4"
},
{
"ordinal": 4,
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"ordinal": 7,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"ordinal": 8,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"ordinal": 9,
"name": "revoked_at",
"type_info": "Timestamptz"
}
@@ -53,6 +63,8 @@
false,
false,
true,
true,
true,
false,
false,
true,
@@ -60,5 +72,5 @@
true
]
},
"hash": "fca331753aeccddbad96d06fc9d066dcefebe978a7af477bb6b55faa1d31e9b1"
"hash": "1ba7c2d8929e3c7d9af7afe92015d3a07eb4609827d97b3477b020059c8a2d4f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registration_tokens\n SET username = $2\n WHERE user_registration_token_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "42ae3db131c3237ecbcc57451a09b72a72bbbda53d0f03e20c1471bc120c492b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_registration_token_id,\n token,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE user_registration_token_id = $1\n ",
"query": "\n SELECT user_registration_token_id,\n token,\n username,\n email,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE user_registration_token_id = $1\n ",
"describe": {
"columns": [
{
@@ -15,31 +15,41 @@
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "usage_limit",
"type_info": "Int4"
},
{
"ordinal": 3,
"ordinal": 5,
"name": "times_used",
"type_info": "Int4"
},
{
"ordinal": 4,
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"ordinal": 7,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"ordinal": 8,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"ordinal": 9,
"name": "revoked_at",
"type_info": "Timestamptz"
}
@@ -53,6 +63,8 @@
false,
false,
true,
true,
true,
false,
false,
true,
@@ -60,5 +72,5 @@
true
]
},
"hash": "d0355d4e98bec6120f17d8cf81ac8c30ed19e9cebd0c8e7c7918b1c3ca0e3cba"
"hash": "4cebc83f7699b61dd5704b82f25553eff02d3fb708071a60d2d5f55a35945218"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_registration_tokens\n (user_registration_token_id, token, usage_limit, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ",
"query": "\n INSERT INTO user_registration_tokens\n (user_registration_token_id, token, usage_limit, created_at, expires_at, username, email)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -9,10 +9,12 @@
"Text",
"Int4",
"Timestamptz",
"Timestamptz"
"Timestamptz",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "89edaec8661e435c3b71bb9b995cd711eb78a4d39608e897432d6124cd135938"
"hash": "8e001a260d28c537f525e35f70e5cc4ea771124820e2f6f8cfc5b958ab21eecc"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_registration_tokens\n SET email = $2\n WHERE user_registration_token_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "e73b3604e554d17a8d2672c1f32b80274c6062889c2ba650049694bb4f7b2aa7"
}
@@ -0,0 +1,10 @@
-- Copyright 2026 Element Creations Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-- Please see LICENSE files in the repository root for full details.
-- Add optional username and email columns to registration tokens,
-- allowing admins to provision tokens that pre-fill these fields.
ALTER TABLE "user_registration_tokens"
ADD COLUMN "username" TEXT,
ADD COLUMN "email" TEXT;
+2
View File
@@ -210,6 +210,8 @@ pub enum UserRegistrationTokens {
Table,
UserRegistrationTokenId,
Token,
Username,
Email,
UsageLimit,
TimesUsed,
CreatedAt,
+159 -10
View File
@@ -1,4 +1,4 @@
// Copyright 2026 Element Creations Ltd.
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -47,6 +47,8 @@ impl<'c> PgUserRegistrationTokenRepository<'c> {
struct UserRegistrationTokenLookup {
user_registration_token_id: Uuid,
token: String,
username: Option<String>,
email: Option<String>,
usage_limit: Option<i32>,
times_used: i32,
created_at: DateTime<Utc>,
@@ -211,6 +213,8 @@ impl TryFrom<UserRegistrationTokenLookup> for UserRegistrationToken {
Ok(UserRegistrationToken {
id,
token: res.token,
username: res.username,
email: res.email,
usage_limit,
times_used,
created_at: res.created_at,
@@ -250,6 +254,17 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Token)),
UserRegistrationTokenLookupIden::Token,
)
.expr_as(
Expr::col((
UserRegistrationTokens::Table,
UserRegistrationTokens::Username,
)),
UserRegistrationTokenLookupIden::Username,
)
.expr_as(
Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Email)),
UserRegistrationTokenLookupIden::Email,
)
.expr_as(
Expr::col((
UserRegistrationTokens::Table,
@@ -362,6 +377,8 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
r#"
SELECT user_registration_token_id,
token,
username,
email,
usage_limit,
times_used,
created_at,
@@ -402,6 +419,8 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
r#"
SELECT user_registration_token_id,
token,
username,
email,
usage_limit,
times_used,
created_at,
@@ -440,6 +459,8 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
token: String,
usage_limit: Option<u32>,
expires_at: Option<DateTime<Utc>>,
username: Option<String>,
email: Option<String>,
) -> Result<UserRegistrationToken, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_rng(created_at, rng);
@@ -452,14 +473,16 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
sqlx::query!(
r#"
INSERT INTO user_registration_tokens
(user_registration_token_id, token, usage_limit, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5)
(user_registration_token_id, token, usage_limit, created_at, expires_at, username, email)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
Uuid::from(id),
&token,
usage_limit_i32,
created_at,
expires_at,
username.as_deref(),
email.as_deref(),
)
.traced()
.execute(&mut *self.conn)
@@ -468,6 +491,8 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
Ok(UserRegistrationToken {
id,
token,
username,
email,
usage_limit,
times_used: 0,
created_at,
@@ -657,6 +682,74 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
Ok(token)
}
#[tracing::instrument(
name = "db.user_registration_token.set_username",
skip_all,
fields(
db.query.text,
user_registration_token.id = %token.id,
),
err,
)]
async fn set_username(
&mut self,
mut token: UserRegistrationToken,
username: Option<String>,
) -> Result<UserRegistrationToken, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE user_registration_tokens
SET username = $2
WHERE user_registration_token_id = $1
"#,
Uuid::from(token.id),
username.as_deref(),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
token.username = username;
Ok(token)
}
#[tracing::instrument(
name = "db.user_registration_token.set_email",
skip_all,
fields(
db.query.text,
user_registration_token.id = %token.id,
),
err,
)]
async fn set_email(
&mut self,
mut token: UserRegistrationToken,
email: Option<String>,
) -> Result<UserRegistrationToken, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE user_registration_tokens
SET email = $2
WHERE user_registration_token_id = $1
"#,
Uuid::from(token.id),
email.as_deref(),
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
token.email = email;
Ok(token)
}
}
#[cfg(test)]
@@ -680,7 +773,15 @@ mod tests {
// Create a token
let token = repo
.user_registration_token()
.add(&mut rng, &clock, "test_token".to_owned(), None, None)
.add(
&mut rng,
&clock,
"test_token".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -725,7 +826,15 @@ mod tests {
// Create a token without expiry
let token = repo
.user_registration_token()
.add(&mut rng, &clock, "test_token_expiry".to_owned(), None, None)
.add(
&mut rng,
&clock,
"test_token_expiry".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -764,7 +873,15 @@ mod tests {
// Create a token without usage limit
let token = repo
.user_registration_token()
.add(&mut rng, &clock, "test_token_limit".to_owned(), None, None)
.add(
&mut rng,
&clock,
"test_token_limit".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
@@ -813,14 +930,30 @@ mod tests {
// 1. A regular token
let _token1 = repo
.user_registration_token()
.add(&mut rng, &clock, "token1".to_owned(), None, None)
.add(
&mut rng,
&clock,
"token1".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
// 2. A token that has been used
let token2 = repo
.user_registration_token()
.add(&mut rng, &clock, "token2".to_owned(), None, None)
.add(
&mut rng,
&clock,
"token2".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
let token2 = repo
@@ -833,14 +966,30 @@ mod tests {
let past_time = clock.now() - Duration::days(1);
let token3 = repo
.user_registration_token()
.add(&mut rng, &clock, "token3".to_owned(), None, Some(past_time))
.add(
&mut rng,
&clock,
"token3".to_owned(),
None,
Some(past_time),
None,
None,
)
.await
.unwrap();
// 4. A token that is revoked
let token4 = repo
.user_registration_token()
.add(&mut rng, &clock, "token4".to_owned(), None, None)
.add(
&mut rng,
&clock,
"token4".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
let token4 = repo
@@ -150,10 +150,13 @@ pub trait UserRegistrationTokenRepository: Send + Sync {
/// * `token`: The token string
/// * `usage_limit`: Optional limit on how many times the token can be used
/// * `expires_at`: Optional expiration time for the token
/// * `username`: Optional username to impose on the registering user
/// * `email`: Optional email to impose on the registering user
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
#[expect(clippy::too_many_arguments)]
async fn add(
&mut self,
rng: &mut (dyn Rng + Send),
@@ -161,6 +164,8 @@ pub trait UserRegistrationTokenRepository: Send + Sync {
token: String,
usage_limit: Option<u32>,
expires_at: Option<DateTime<Utc>>,
username: Option<String>,
email: Option<String>,
) -> Result<UserRegistrationToken, Self::Error>;
/// Increment the usage count of a [`UserRegistrationToken`]
@@ -244,6 +249,38 @@ pub trait UserRegistrationTokenRepository: Send + Sync {
usage_limit: Option<u32>,
) -> Result<UserRegistrationToken, Self::Error>;
/// Set or clear the username imposed by this [`UserRegistrationToken`]
///
/// # Parameters
///
/// * `token`: The [`UserRegistrationToken`] to update
/// * `username`: The new username, or `None` to clear it
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn set_username(
&mut self,
token: UserRegistrationToken,
username: Option<String>,
) -> Result<UserRegistrationToken, Self::Error>;
/// Set or clear the email imposed by this [`UserRegistrationToken`]
///
/// # Parameters
///
/// * `token`: The [`UserRegistrationToken`] to update
/// * `email`: The new email, or `None` to clear it
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn set_email(
&mut self,
token: UserRegistrationToken,
email: Option<String>,
) -> Result<UserRegistrationToken, Self::Error>;
/// List [`UserRegistrationToken`]s based on the provided filter
///
/// Returns a list of matching [`UserRegistrationToken`]s
@@ -286,6 +323,8 @@ repository_impl!(UserRegistrationTokenRepository:
token: String,
usage_limit: Option<u32>,
expires_at: Option<DateTime<Utc>>,
username: Option<String>,
email: Option<String>,
) -> Result<UserRegistrationToken, Self::Error>;
async fn use_token(
&mut self,
@@ -311,6 +350,16 @@ repository_impl!(UserRegistrationTokenRepository:
token: UserRegistrationToken,
usage_limit: Option<u32>,
) -> Result<UserRegistrationToken, Self::Error>;
async fn set_username(
&mut self,
token: UserRegistrationToken,
username: Option<String>,
) -> Result<UserRegistrationToken, Self::Error>;
async fn set_email(
&mut self,
token: UserRegistrationToken,
email: Option<String>,
) -> Result<UserRegistrationToken, Self::Error>;
async fn list(
&mut self,
filter: UserRegistrationTokenFilter,
+14
View File
@@ -7389,6 +7389,20 @@
"null"
],
"format": "date-time"
},
"username": {
"description": "A username to impose on the registering user. If set, the user cannot\nchoose their own username.",
"type": [
"string",
"null"
]
},
"email": {
"description": "An email address to impose on the registering user. If set, the user\ncannot choose their own email.",
"type": [
"string",
"null"
]
}
}
},