diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 50aa7250e..b8d6c6a52 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -27,7 +27,9 @@ use mas_matrix::HomeserverConnection; use mas_matrix_synapse::SynapseConnection; use mas_storage::{ compat::{CompatAccessTokenRepository, CompatSessionRepository}, - job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob, SyncDevicesJob}, + job::{ + DeactivateUserJob, JobRepositoryExt, ProvisionUserJob, ReactivateUserJob, SyncDevicesJob, + }, user::{UserEmailRepository, UserPasswordRepository, UserRepository}, Clock, RepositoryAccess, SystemClock, }; @@ -488,9 +490,11 @@ impl Options { .await? .context("User not found")?; - info!(%user.id, "Unlocking user"); + warn!(%user.id, "User scheduling user reactivation"); + repo.job() + .schedule_job(ReactivateUserJob::new(&user)) + .await?; - repo.user().unlock(user).await?; repo.into_inner().commit().await?; Ok(ExitCode::SUCCESS) diff --git a/crates/handlers/src/graphql/model/matrix.rs b/crates/handlers/src/graphql/model/matrix.rs index 0903439aa..2efb7f95e 100644 --- a/crates/handlers/src/graphql/model/matrix.rs +++ b/crates/handlers/src/graphql/model/matrix.rs @@ -1,4 +1,4 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. +// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,6 +25,9 @@ pub struct MatrixUser { /// The avatar URL of the user, if any. avatar_url: Option, + + /// Whether the user is deactivated on the homeserver. + deactivated: bool, } impl MatrixUser { @@ -40,6 +43,7 @@ impl MatrixUser { mxid, display_name: info.displayname, avatar_url: info.avatar_url, + deactivated: info.deactivated, }) } } diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 7568c2ee9..1fa64c6c8 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -141,6 +141,52 @@ impl LockUserPayload { } } +/// The input for the `unlockUser` mutation. +#[derive(InputObject)] +struct UnlockUserInput { + /// The ID of the user to unlock + user_id: ID, +} + +/// The status of the `unlockUser` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum UnlockUserStatus { + /// The user was unlocked. + Unlocked, + + /// The user was not found. + NotFound, +} + +/// The payload for the `unlockUser` mutation. +#[derive(Description)] +enum UnlockUserPayload { + /// The user was unlocked. + Unlocked(mas_data_model::User), + + /// The user was not found. + NotFound, +} + +#[Object(use_type_description)] +impl UnlockUserPayload { + /// Status of the operation + async fn status(&self) -> UnlockUserStatus { + match self { + Self::Unlocked(_) => UnlockUserStatus::Unlocked, + Self::NotFound => UnlockUserStatus::NotFound, + } + } + + /// The user that was unlocked. + async fn user(&self) -> Option { + match self { + Self::Unlocked(user) => Some(User(user.clone())), + Self::NotFound => None, + } + } +} + /// The input for the `setCanRequestAdmin` mutation. #[derive(InputObject)] struct SetCanRequestAdminInput { @@ -382,6 +428,40 @@ impl UserMutations { Ok(LockUserPayload::Locked(user)) } + /// Unlock a user. This is only available to administrators. + async fn unlock_user( + &self, + ctx: &Context<'_>, + input: UnlockUserInput, + ) -> Result { + let state = ctx.state(); + let requester = ctx.requester(); + let matrix = state.homeserver_connection(); + + if !requester.is_admin() { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let mut repo = state.repository().await?; + let user_id = NodeType::User.extract_ulid(&input.user_id)?; + let user = repo.user().lookup(user_id).await?; + + let Some(user) = user else { + return Ok(UnlockUserPayload::NotFound); + }; + + // Call the homeserver synchronously to unlock the user + let mxid = matrix.mxid(&user.username); + matrix.reactivate_user(&mxid).await?; + + // Now unlock the user in our database + let user = repo.user().unlock(user).await?; + + repo.save().await?; + + Ok(UnlockUserPayload::Unlocked(user)) + } + /// Set whether a user can request admin. This is only available to /// administrators. async fn set_can_request_admin( diff --git a/crates/matrix-synapse/src/lib.rs b/crates/matrix-synapse/src/lib.rs index 77a3c711b..ecc01e2b7 100644 --- a/crates/matrix-synapse/src/lib.rs +++ b/crates/matrix-synapse/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. +// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -131,6 +131,9 @@ struct SynapseUser { #[serde(default, skip_serializing_if = "Option::is_none")] external_ids: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + deactivated: Option, } #[derive(Deserialize)] @@ -214,6 +217,7 @@ impl HomeserverConnection for SynapseConnection { Ok(MatrixUser { displayname: body.display_name, avatar_url: body.avatar_url, + deactivated: body.deactivated.unwrap_or(false), }) } @@ -539,6 +543,50 @@ impl HomeserverConnection for SynapseConnection { Ok(()) } + #[tracing::instrument( + name = "homeserver.reactivate_user", + skip_all, + fields( + matrix.homeserver = self.homeserver, + matrix.mxid = mxid, + ), + err(Debug), + )] + async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> { + let body = SynapseUser { + deactivated: Some(false), + ..SynapseUser::default() + }; + + let mut client = self + .http_client_factory + .client("homeserver.reactivate_user") + .request_bytes_to_body() + .json_request() + .response_body_to_bytes() + .catch_http_errors(catch_homeserver_error); + + let mxid = urlencoding::encode(mxid); + let request = self + .put(&format!("_synapse/admin/v2/users/{mxid}")) + .body(body)?; + + let response = client + .ready() + .await? + .call(request) + .await + .context("Failed to provision user in Synapse")?; + + match response.status() { + StatusCode::CREATED | StatusCode::OK => Ok(()), + code => Err(anyhow::anyhow!( + "Failed to provision user in Synapse: {}", + code + )), + } + } + #[tracing::instrument( name = "homeserver.set_displayname", skip_all, diff --git a/crates/matrix/src/lib.rs b/crates/matrix/src/lib.rs index 6b4160923..779c263f6 100644 --- a/crates/matrix/src/lib.rs +++ b/crates/matrix/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. +// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ pub type BoxHomeserverConnection = pub struct MatrixUser { pub displayname: Option, pub avatar_url: Option, + pub deactivated: bool, } #[derive(Debug, Default)] @@ -288,6 +289,18 @@ pub trait HomeserverConnection: Send + Sync { /// be deleted. async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), Self::Error>; + /// Reactivate a user on the homeserver. + /// + /// # Parameters + /// + /// * `mxid` - The Matrix ID of the user to reactivate. + /// + /// # Errors + /// + /// Returns an error if the homeserver is unreachable or the user could not + /// be reactivated. + async fn reactivate_user(&self, mxid: &str) -> Result<(), Self::Error>; + /// Set the displayname of a user on the homeserver. /// /// # Parameters @@ -362,6 +375,10 @@ impl HomeserverConnection for &T (**self).delete_user(mxid, erase).await } + async fn reactivate_user(&self, mxid: &str) -> Result<(), Self::Error> { + (**self).reactivate_user(mxid).await + } + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error> { (**self).set_displayname(mxid, displayname).await } @@ -412,6 +429,10 @@ impl HomeserverConnection for Arc { (**self).delete_user(mxid, erase).await } + async fn reactivate_user(&self, mxid: &str) -> Result<(), Self::Error> { + (**self).reactivate_user(mxid).await + } + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error> { (**self).set_displayname(mxid, displayname).await } diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index d7f0421ef..d834dec01 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -1,4 +1,4 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. +// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ struct MockUser { devices: HashSet, emails: Option>, cross_signing_reset_allowed: bool, + deactivated: bool, } /// A mock implementation of a [`HomeserverConnection`], which never fails and @@ -69,6 +70,7 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(MatrixUser { displayname: user.displayname.clone(), avatar_url: user.avatar_url.clone(), + deactivated: user.deactivated, }) } @@ -82,6 +84,7 @@ impl crate::HomeserverConnection for HomeserverConnection { devices: HashSet::new(), emails: None, cross_signing_reset_allowed: false, + deactivated: false, }); anyhow::ensure!( @@ -140,6 +143,7 @@ impl crate::HomeserverConnection for HomeserverConnection { let user = users.get_mut(mxid).context("User not found")?; user.devices.clear(); user.emails = None; + user.deactivated = true; if erase { user.avatar_url = None; user.displayname = None; @@ -148,6 +152,14 @@ impl crate::HomeserverConnection for HomeserverConnection { Ok(()) } + async fn reactivate_user(&self, mxid: &str) -> Result<(), Self::Error> { + let mut users = self.users.write().await; + let user = users.get_mut(mxid).context("User not found")?; + user.deactivated = false; + + Ok(()) + } + async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), Self::Error> { let mut users = self.users.write().await; let user = users.get_mut(mxid).context("User not found")?; diff --git a/crates/storage/src/job.rs b/crates/storage/src/job.rs index 962dfe0ff..1ac66fb94 100644 --- a/crates/storage/src/job.rs +++ b/crates/storage/src/job.rs @@ -455,6 +455,34 @@ mod jobs { const NAME: &'static str = "deactivate-user"; } + /// A job to reactivate a user + #[derive(Serialize, Deserialize, Debug, Clone)] + pub struct ReactivateUserJob { + user_id: Ulid, + } + + impl ReactivateUserJob { + /// Create a new job to reactivate a user + /// + /// # Parameters + /// + /// * `user` - The user to reactivate + #[must_use] + pub fn new(user: &User) -> Self { + Self { user_id: user.id } + } + + /// The ID of the user to reactivate + #[must_use] + pub fn user_id(&self) -> Ulid { + self.user_id + } + } + + impl Job for ReactivateUserJob { + const NAME: &'static str = "reactivate-user"; + } + /// Send account recovery emails #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SendAccountRecoveryEmailsJob { @@ -489,6 +517,6 @@ mod jobs { } pub use self::jobs::{ - DeactivateUserJob, DeleteDeviceJob, ProvisionDeviceJob, ProvisionUserJob, + DeactivateUserJob, DeleteDeviceJob, ProvisionDeviceJob, ProvisionUserJob, ReactivateUserJob, SendAccountRecoveryEmailsJob, SyncDevicesJob, VerifyEmailJob, }; diff --git a/crates/tasks/src/user.rs b/crates/tasks/src/user.rs index 2eb2d3a23..fe64b6c16 100644 --- a/crates/tasks/src/user.rs +++ b/crates/tasks/src/user.rs @@ -1,4 +1,4 @@ -// Copyright 2023 The Matrix.org Foundation C.I.C. +// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ use anyhow::Context; use apalis_core::{context::JobContext, executor::TokioExecutor, monitor::Monitor}; use mas_storage::{ - job::{DeactivateUserJob, JobWithSpanContext}, + job::{DeactivateUserJob, JobWithSpanContext, ReactivateUserJob}, user::UserRepository, RepositoryAccess, }; @@ -54,7 +54,8 @@ async fn deactivate_user( // TODO: delete the sessions & access tokens - // Before calling back to the homeserver, commit the changes to the database + // Before calling back to the homeserver, commit the changes to the database, as + // we want the user to be locked out as soon as possible repo.save().await?; let mxid = matrix.mxid(&user.username); @@ -64,6 +65,39 @@ async fn deactivate_user( Ok(()) } +/// Job to reactivate a user, both locally and on the Matrix homeserver. +#[tracing::instrument( + name = "job.reactivate_user", + fields(user.id = %job.user_id()), + skip_all, + err(Debug), +)] +pub async fn reactivate_user( + job: JobWithSpanContext, + ctx: JobContext, +) -> Result<(), anyhow::Error> { + let state = ctx.state(); + let matrix = state.matrix_connection(); + let mut repo = state.repository().await?; + + let user = repo + .user() + .lookup(job.user_id()) + .await? + .context("User not found")?; + + let mxid = matrix.mxid(&user.username); + info!("Reactivating user {} on homeserver", mxid); + matrix.reactivate_user(&mxid).await?; + + // We want to unlock the user from our side only once it has been reactivated on + // the homeserver + let _user = repo.user().unlock(user).await?; + repo.save().await?; + + Ok(()) +} + pub(crate) fn register( suffix: &str, monitor: Monitor, @@ -73,5 +107,10 @@ pub(crate) fn register( let deactivate_user_worker = crate::build!(DeactivateUserJob => deactivate_user, suffix, state, storage_factory); - monitor.register(deactivate_user_worker) + let reactivate_user_worker = + crate::build!(ReactivateUserJob => reactivate_user, suffix, state, storage_factory); + + monitor + .register(deactivate_user_worker) + .register(reactivate_user_worker) } diff --git a/frontend/schema.graphql b/frontend/schema.graphql index a5eab5b3e..6e007a8d2 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -698,6 +698,10 @@ type MatrixUser { The avatar URL of the user, if any. """ avatarUrl: String + """ + Whether the user is deactivated on the homeserver. + """ + deactivated: Boolean! } """ @@ -735,6 +739,10 @@ type Mutation { """ lockUser(input: LockUserInput!): LockUserPayload! """ + Unlock a user. This is only available to administrators. + """ + unlockUser(input: UnlockUserInput!): UnlockUserPayload! + """ Set whether a user can request admin. This is only available to administrators. """ @@ -1399,6 +1407,44 @@ type SiteConfig implements Node { id: ID! } +""" +The input for the `unlockUser` mutation. +""" +input UnlockUserInput { + """ + The ID of the user to unlock + """ + userId: ID! +} + +""" +The payload for the `unlockUser` mutation. +""" +type UnlockUserPayload { + """ + Status of the operation + """ + status: UnlockUserStatus! + """ + The user that was unlocked. + """ + user: User +} + +""" +The status of the `unlockUser` mutation. +""" +enum UnlockUserStatus { + """ + The user was unlocked. + """ + UNLOCKED + """ + The user was not found. + """ + NOT_FOUND +} + type UpstreamOAuth2Link implements Node & CreationEvent { """ ID of the object. diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index dea242625..f544593cb 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -449,6 +449,8 @@ export type MatrixUser = { __typename?: 'MatrixUser'; /** The avatar URL of the user, if any. */ avatarUrl?: Maybe; + /** Whether the user is deactivated on the homeserver. */ + deactivated: Scalars['Boolean']['output']; /** The display name of the user, if any. */ displayName?: Maybe; /** The Matrix ID of the user. */ @@ -497,6 +499,8 @@ export type Mutation = { setPassword: SetPasswordPayload; /** Set an email address as primary */ setPrimaryEmail: SetPrimaryEmailPayload; + /** Unlock a user. This is only available to administrators. */ + unlockUser: UnlockUserPayload; /** Submit a verification code for an email address */ verifyEmail: VerifyEmailPayload; }; @@ -586,6 +590,12 @@ export type MutationSetPrimaryEmailArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationUnlockUserArgs = { + input: UnlockUserInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationVerifyEmailArgs = { input: VerifyEmailInput; @@ -1040,6 +1050,29 @@ export type SiteConfig = Node & { tosUri?: Maybe; }; +/** The input for the `unlockUser` mutation. */ +export type UnlockUserInput = { + /** The ID of the user to unlock */ + userId: Scalars['ID']['input']; +}; + +/** The payload for the `unlockUser` mutation. */ +export type UnlockUserPayload = { + __typename?: 'UnlockUserPayload'; + /** Status of the operation */ + status: UnlockUserStatus; + /** The user that was unlocked. */ + user?: Maybe; +}; + +/** The status of the `unlockUser` mutation. */ +export enum UnlockUserStatus { + /** The user was not found. */ + NotFound = 'NOT_FOUND', + /** The user was unlocked. */ + Unlocked = 'UNLOCKED' +} + export type UpstreamOAuth2Link = CreationEvent & Node & { __typename?: 'UpstreamOAuth2Link'; /** When the object was created. */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index 20fd4d780..1971ae5cb 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -1120,6 +1120,17 @@ export default { }, "args": [] }, + { + "name": "deactivated", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + }, + "args": [] + }, { "name": "displayName", "type": { @@ -1468,6 +1479,29 @@ export default { } ] }, + { + "name": "unlockUser", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "UnlockUserPayload", + "ofType": null + } + }, + "args": [ + { + "name": "input", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + } + } + ] + }, { "name": "verifyEmail", "type": { @@ -2622,6 +2656,33 @@ export default { } ] }, + { + "kind": "OBJECT", + "name": "UnlockUserPayload", + "fields": [ + { + "name": "status", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Any" + } + }, + "args": [] + }, + { + "name": "user", + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "args": [] + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "UpstreamOAuth2Link",