From c2f72eccd18aaa6736840363932973275c3419df Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 13 Mar 2026 15:46:44 +0000 Subject: [PATCH 1/3] Update ProvisionRequest with locked flag --- crates/handlers/src/admin/v1/users/add.rs | 2 +- crates/handlers/src/admin/v1/users/reactivate.rs | 4 ++-- crates/handlers/src/compat/login.rs | 6 +++--- crates/handlers/src/compat/tests.rs | 2 +- crates/handlers/src/graphql/tests.rs | 2 +- crates/handlers/src/oauth2/introspection.rs | 4 ++-- crates/matrix-synapse/src/modern.rs | 3 +++ crates/matrix/src/lib.rs | 11 ++++++++++- crates/matrix/src/mock.rs | 2 +- crates/tasks/src/matrix.rs | 8 ++++++-- 10 files changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/handlers/src/admin/v1/users/add.rs b/crates/handlers/src/admin/v1/users/add.rs index 07e87fb40..9b3307e8d 100644 --- a/crates/handlers/src/admin/v1/users/add.rs +++ b/crates/handlers/src/admin/v1/users/add.rs @@ -166,7 +166,7 @@ pub async fn handler( let user = repo.user().add(&mut rng, &clock, params.username).await?; homeserver - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .map_err(RouteError::Homeserver)?; diff --git a/crates/handlers/src/admin/v1/users/reactivate.rs b/crates/handlers/src/admin/v1/users/reactivate.rs index 835ef0b40..01f83888a 100644 --- a/crates/handlers/src/admin/v1/users/reactivate.rs +++ b/crates/handlers/src/admin/v1/users/reactivate.rs @@ -129,7 +129,7 @@ mod tests { // because this endpoint will try to reactivate it state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); state @@ -181,7 +181,7 @@ mod tests { // Provision the user on the homeserver state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index ebb5d32c1..6aa0fb53d 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -935,7 +935,7 @@ mod tests { .unwrap(); state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, locked)) .await .unwrap(); @@ -1238,7 +1238,7 @@ mod tests { state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); @@ -1343,7 +1343,7 @@ mod tests { state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); diff --git a/crates/handlers/src/compat/tests.rs b/crates/handlers/src/compat/tests.rs index cb6b76aba..da1e8e501 100644 --- a/crates/handlers/src/compat/tests.rs +++ b/crates/handlers/src/compat/tests.rs @@ -223,7 +223,7 @@ async fn create_test_user(state: &TestState, username: &str) -> mas_data_model:: // Provision the user on the homeserver state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index 888d477d0..7fed79c09 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -530,7 +530,7 @@ async fn test_oauth2_client_credentials(pool: PgPool) { // so we need to do it manually state .homeserver_connection - .provision_user(&ProvisionRequest::new("alice", user_id)) + .provision_user(&ProvisionRequest::new("alice", user_id, false)) .await .unwrap(); diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index 17f508921..c093ed7bc 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -805,7 +805,7 @@ mod tests { state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); @@ -1005,7 +1005,7 @@ mod tests { state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); diff --git a/crates/matrix-synapse/src/modern.rs b/crates/matrix-synapse/src/modern.rs index 3d70f52de..198072b31 100644 --- a/crates/matrix-synapse/src/modern.rs +++ b/crates/matrix-synapse/src/modern.rs @@ -141,6 +141,8 @@ impl HomeserverConnection for SynapseConnection { set_emails: Option>, #[serde(skip_serializing_if = "std::ops::Not::not")] unset_emails: bool, + #[serde(skip_serializing_if = "Option::is_none")] + locked: Option, } let mut body = Request { @@ -151,6 +153,7 @@ impl HomeserverConnection for SynapseConnection { unset_avatar_url: false, set_emails: None, unset_emails: false, + locked: Some(request.locked()), }; request.on_displayname(|displayname| match displayname { diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index f1fbe9c83..cb8534eb7 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -33,6 +33,7 @@ enum FieldAction { pub struct ProvisionRequest { localpart: String, sub: String, + locked: bool, displayname: FieldAction, avatar_url: FieldAction, emails: FieldAction>, @@ -45,11 +46,13 @@ impl ProvisionRequest { /// /// * `localpart` - The localpart of the user to provision. /// * `sub` - The `sub` of the user, aka the internal ID. + /// * `locked` - Whether the user is locked. #[must_use] - pub fn new(localpart: impl Into, sub: impl Into) -> Self { + pub fn new(localpart: impl Into, sub: impl Into, locked: bool) -> Self { Self { localpart: localpart.into(), sub: sub.into(), + locked, displayname: FieldAction::DoNothing, avatar_url: FieldAction::DoNothing, emails: FieldAction::DoNothing, @@ -68,6 +71,12 @@ impl ProvisionRequest { &self.localpart } + /// Get the locked flag of the user to provision + #[must_use] + pub fn locked(&self) -> bool { + self.locked + } + /// Ask to set the displayname of the user. /// /// # Parameters diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 4180597e2..1334d7e4f 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -234,7 +234,7 @@ mod tests { assert!(conn.upsert_device("test", device, None).await.is_err()); assert!(conn.delete_device("test", device).await.is_err()); - let request = ProvisionRequest::new("test", "test") + let request = ProvisionRequest::new("test", "test", false) .set_displayname("Test User".into()) .set_avatar_url("mxc://example.org/1234567890".into()) .set_emails(vec!["test@example.org".to_owned()]); diff --git a/crates/tasks/src/matrix.rs b/crates/tasks/src/matrix.rs index 68905fe53..2f12ce6da 100644 --- a/crates/tasks/src/matrix.rs +++ b/crates/tasks/src/matrix.rs @@ -60,8 +60,12 @@ impl RunnableJob for ProvisionUserJob { .into_iter() .map(|email| email.email) .collect(); - let mut request = - ProvisionRequest::new(user.username.clone(), user.sub.clone()).set_emails(emails); + let mut request = ProvisionRequest::new( + user.username.clone(), + user.sub.clone(), + user.locked_at.is_some(), + ) + .set_emails(emails); if let Some(display_name) = self.display_name_to_set() { request = request.set_displayname(display_name.to_owned()); From 5b7b4d61ec7d22572165dc98f7081d3a89309075 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 13 Mar 2026 17:37:21 +0000 Subject: [PATCH 2/3] Schedule ProvisionUserJob after locking/unlocking user --- crates/cli/src/commands/manage.rs | 12 ++++++++++++ crates/handlers/src/admin/v1/users/lock.rs | 11 ++++++++++- crates/handlers/src/admin/v1/users/unlock.rs | 19 +++++++++++++++---- crates/handlers/src/graphql/mutations/user.rs | 14 ++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 5bbd870ea..65aceda4d 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -638,6 +638,12 @@ impl Options { // synchronously yet. let user = repo.user().lock(&clock, user).await?; + // Schedule a job to provision the user so that the lock flag is propagated + // to Synapse + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + if deactivate { warn!(%user.id, "Scheduling user deactivation"); repo.queue_job() @@ -668,6 +674,12 @@ impl Options { .await? .context("User not found")?; + // Schedule a job to provision the user so that the lock flag is propagated + // to Synapse + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + if reactivate { warn!(%user.id, "Scheduling user reactivation"); repo.queue_job() diff --git a/crates/handlers/src/admin/v1/users/lock.rs b/crates/handlers/src/admin/v1/users/lock.rs index 6d6ccfcf9..e2ebfa574 100644 --- a/crates/handlers/src/admin/v1/users/lock.rs +++ b/crates/handlers/src/admin/v1/users/lock.rs @@ -4,10 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use aide::{OperationIo, transform::TransformOperation}; +use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{ProvisionUserJob, QueueJobRepositoryExt}; use ulid::Ulid; use crate::{ @@ -69,6 +71,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + NoApi(mut rng): NoApi, id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; @@ -80,6 +83,12 @@ pub async fn handler( let user = repo.user().lock(&clock, user).await?; + // Schedule a job to provision the user so that the lock flag is propagated + // to Synapse + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + repo.save().await?; Ok(Json(SingleResponse::new( diff --git a/crates/handlers/src/admin/v1/users/unlock.rs b/crates/handlers/src/admin/v1/users/unlock.rs index 72987a9ff..c14d0b5d3 100644 --- a/crates/handlers/src/admin/v1/users/unlock.rs +++ b/crates/handlers/src/admin/v1/users/unlock.rs @@ -4,10 +4,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use aide::{OperationIo, transform::TransformOperation}; +use aide::{NoApi, OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_data_model::BoxRng; +use mas_storage::queue::{ProvisionUserJob, QueueJobRepositoryExt}; use ulid::Ulid; use crate::{ @@ -66,7 +68,10 @@ This DOES NOT reactivate a deactivated user, which will remain unavailable until #[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all)] pub async fn handler( - CallContext { mut repo, .. }: CallContext, + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; @@ -78,6 +83,12 @@ pub async fn handler( let user = repo.user().unlock(user).await?; + // Schedule a job to provision the user so that the lock flag is propagated + // to Synapse + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + repo.save().await?; Ok(Json(SingleResponse::new( @@ -115,7 +126,7 @@ mod tests { // reactivate it state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); @@ -151,7 +162,7 @@ mod tests { // Provision the user on the homeserver state .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub)) + .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) .await .unwrap(); // but then deactivate it diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 355c7d0ac..376652a46 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -551,6 +551,12 @@ impl UserMutations { let user = repo.user().lock(&state.clock(), user).await?; + // Schedule a job to provision the user so that the lock flag is propagated + // to Synapse + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + if deactivate { info!(%user.id, "Scheduling deactivation of user"); repo.queue_job() @@ -570,6 +576,8 @@ impl UserMutations { input: UnlockUserInput, ) -> Result { let state = ctx.state(); + let clock = state.clock(); + let mut rng = state.rng(); let requester = ctx.requester(); let matrix = state.homeserver_connection(); @@ -592,6 +600,12 @@ impl UserMutations { let user = repo.user().reactivate(user).await?; let user = repo.user().unlock(user).await?; + // Schedule a job to provision the user so that the lock flag is propagated + // to Synapse + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + repo.save().await?; Ok(UnlockUserPayload::Unlocked(user)) From 1426ea4b2252f89807aaa3c5d02aeaa786fdca78 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Tue, 17 Mar 2026 11:19:34 +0000 Subject: [PATCH 3/3] Add support for locking to the mock homeserver and use in tests --- crates/handlers/src/admin/v1/users/lock.rs | 34 +++++++++++++++++- crates/handlers/src/admin/v1/users/unlock.rs | 36 +++++++++++++++++--- crates/matrix/src/mock.rs | 22 ++++++++++-- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/crates/handlers/src/admin/v1/users/lock.rs b/crates/handlers/src/admin/v1/users/lock.rs index e2ebfa574..d80cc6451 100644 --- a/crates/handlers/src/admin/v1/users/lock.rs +++ b/crates/handlers/src/admin/v1/users/lock.rs @@ -102,7 +102,11 @@ mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; use mas_data_model::Clock; - use mas_storage::{RepositoryAccess, user::UserRepository}; + use mas_storage::{ + RepositoryAccess, + queue::{ProvisionUserJob, QueueJobRepositoryExt}, + user::UserRepository, + }; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -119,8 +123,25 @@ mod tests { .add(&mut state.rng(), &state.clock, "alice".to_owned()) .await .unwrap(); + + repo.queue_job() + .schedule_job(&mut state.rng(), &state.clock, ProvisionUserJob::new(&user)) + .await + .unwrap(); + repo.save().await.unwrap(); + state.run_jobs_in_queue().await; + assert!( + !state + .homeserver_connection + .query_user_raw("alice") + .await + .unwrap() + .locked, + "User should not be locked at start of test" + ); + let request = Request::post(format!("/api/admin/v1/users/{}/lock", user.id)) .bearer(&token) .empty(); @@ -133,6 +154,17 @@ mod tests { body["data"]["attributes"]["locked_at"], serde_json::json!(state.clock.now()) ); + + state.run_jobs_in_queue().await; + assert!( + state + .homeserver_connection + .query_user_raw("alice") + .await + .unwrap() + .locked, + "User should be locked" + ); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/users/unlock.rs b/crates/handlers/src/admin/v1/users/unlock.rs index c14d0b5d3..d580ab27c 100644 --- a/crates/handlers/src/admin/v1/users/unlock.rs +++ b/crates/handlers/src/admin/v1/users/unlock.rs @@ -102,7 +102,11 @@ mod tests { use hyper::{Request, StatusCode}; use mas_data_model::Clock; use mas_matrix::{HomeserverConnection, ProvisionRequest}; - use mas_storage::{RepositoryAccess, user::UserRepository}; + use mas_storage::{ + RepositoryAccess, + queue::{ProvisionUserJob, QueueJobRepositoryExt}, + user::UserRepository, + }; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -120,16 +124,27 @@ mod tests { .await .unwrap(); let user = repo.user().lock(&state.clock, user).await.unwrap(); - repo.save().await.unwrap(); // Also provision the user on the homeserver, because this endpoint will try to // reactivate it - state - .homeserver_connection - .provision_user(&ProvisionRequest::new(&user.username, &user.sub, false)) + repo.queue_job() + .schedule_job(&mut state.rng(), &state.clock, ProvisionUserJob::new(&user)) .await .unwrap(); + repo.save().await.unwrap(); + + state.run_jobs_in_queue().await; + assert!( + state + .homeserver_connection + .query_user_raw("alice") + .await + .unwrap() + .locked, + "User should be locked at start of test" + ); + let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id)) .bearer(&token) .empty(); @@ -141,6 +156,17 @@ mod tests { body["data"]["attributes"]["locked_at"], serde_json::Value::Null ); + + state.run_jobs_in_queue().await; + assert!( + !state + .homeserver_connection + .query_user_raw("alice") + .await + .unwrap() + .locked, + "User should not be locked" + ); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 1334d7e4f..2dbbf4b9a 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -10,9 +10,10 @@ use anyhow::Context; use async_trait::async_trait; use tokio::sync::RwLock; -use crate::{MatrixUser, ProvisionRequest}; +use crate::{HomeserverConnection as _, MatrixUser, ProvisionRequest}; -struct MockUser { +#[derive(Clone)] +pub struct MockUser { sub: String, avatar_url: Option, displayname: Option, @@ -20,6 +21,7 @@ struct MockUser { emails: Option>, cross_signing_reset_allowed: bool, deactivated: bool, + pub locked: bool, } /// A mock implementation of a [`HomeserverConnection`], which never fails and @@ -50,6 +52,18 @@ impl HomeserverConnection { pub async fn reserve_localpart(&self, localpart: &'static str) { self.reserved_localparts.write().await.insert(localpart); } + + /// Like `query_user` but get the raw test state of the user. + /// + /// # Errors + /// + /// Will fail if the user doesn't exist. + pub async fn query_user_raw(&self, localpart: &str) -> Result { + let mxid = self.mxid(localpart); + let users = self.users.read().await; + let user = users.get(&mxid).context("User not found")?; + Ok(user.clone()) + } } #[async_trait] @@ -85,6 +99,7 @@ impl crate::HomeserverConnection for HomeserverConnection { emails: None, cross_signing_reset_allowed: false, deactivated: false, + locked: false, }); anyhow::ensure!( @@ -104,6 +119,8 @@ impl crate::HomeserverConnection for HomeserverConnection { user.avatar_url = avatar_url.map(ToOwned::to_owned); }); + user.locked = request.locked(); + Ok(inserted) } @@ -219,7 +236,6 @@ impl crate::HomeserverConnection for HomeserverConnection { #[cfg(test)] mod tests { use super::*; - use crate::HomeserverConnection as _; #[tokio::test] async fn test_mock_connection() {