diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index 780d8b0f..e8f25ecc 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -63,6 +63,16 @@ impl User { &self.0.username } + /// When the object was created. + pub async fn created_at(&self) -> DateTime { + self.0.created_at + } + + /// When the user was locked out. + pub async fn locked_at(&self) -> Option> { + self.0.locked_at + } + /// Access to the user's Matrix account information. async fn matrix(&self, ctx: &Context<'_>) -> Result { let state = ctx.state(); diff --git a/crates/graphql/src/mutations/mod.rs b/crates/graphql/src/mutations/mod.rs index c34ac79e..1e48d5b3 100644 --- a/crates/graphql/src/mutations/mod.rs +++ b/crates/graphql/src/mutations/mod.rs @@ -16,6 +16,7 @@ mod browser_session; mod compat_session; mod matrix; mod oauth2_session; +mod user; mod user_email; use async_graphql::MergedObject; @@ -24,6 +25,7 @@ use async_graphql::MergedObject; #[derive(Default, MergedObject)] pub struct Mutation( user_email::UserEmailMutations, + user::UserMutations, oauth2_session::OAuth2SessionMutations, compat_session::CompatSessionMutations, browser_session::BrowserSessionMutations, diff --git a/crates/graphql/src/mutations/user.rs b/crates/graphql/src/mutations/user.rs new file mode 100644 index 00000000..1238c96f --- /dev/null +++ b/crates/graphql/src/mutations/user.rs @@ -0,0 +1,235 @@ +// Copyright 2023 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use async_graphql::{Context, Description, Enum, InputObject, Object, ID}; +use mas_storage::{ + job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob}, + user::UserRepository, +}; +use tracing::info; + +use crate::{ + model::{NodeType, User}, + state::ContextExt, +}; + +#[derive(Default)] +pub struct UserMutations { + _private: (), +} + +/// The input for the `addUser` mutation. +#[derive(InputObject)] +struct AddUserInput { + /// The username of the user to add. + username: String, +} + +/// The status of the `addUser` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum AddUserStatus { + /// The user was added. + Added, + + /// The user already exists. + Exists, + + /// The username is invalid. + Invalid, +} + +/// The payload for the `addUser` mutation. +#[derive(Description)] +enum AddUserPayload { + Added(mas_data_model::User), + Exists(mas_data_model::User), + Invalid, +} + +#[Object(use_type_description)] +impl AddUserPayload { + /// Status of the operation + async fn status(&self) -> AddUserStatus { + match self { + Self::Added(_) => AddUserStatus::Added, + Self::Exists(_) => AddUserStatus::Exists, + Self::Invalid => AddUserStatus::Invalid, + } + } + + /// The user that was added. + async fn user(&self) -> Option { + match self { + Self::Added(user) | Self::Exists(user) => Some(User(user.clone())), + Self::Invalid => None, + } + } +} + +/// The input for the `lockUser` mutation. +#[derive(InputObject)] +struct LockUserInput { + /// The ID of the user to lock. + user_id: ID, + + /// Permanently lock the user. + deactivate: Option, +} + +/// The status of the `lockUser` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum LockUserStatus { + /// The user was locked. + Locked, + + /// The user was not found. + NotFound, +} + +/// The payload for the `lockUser` mutation. +#[derive(Description)] +enum LockUserPayload { + /// The user was locked. + Locked(mas_data_model::User), + + /// The user was not found. + NotFound, +} + +#[Object(use_type_description)] +impl LockUserPayload { + /// Status of the operation + async fn status(&self) -> LockUserStatus { + match self { + Self::Locked(_) => LockUserStatus::Locked, + Self::NotFound => LockUserStatus::NotFound, + } + } + + /// The user that was locked. + async fn user(&self) -> Option { + match self { + Self::Locked(user) => Some(User(user.clone())), + Self::NotFound => None, + } + } +} + +fn valid_username_character(c: char) -> bool { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || c == '=' + || c == '_' + || c == '-' + || c == '.' + || c == '/' + || c == '+' +} + +// XXX: this should probably be moved somewhere else +fn username_valid(username: &str) -> bool { + if username.is_empty() || username.len() > 255 { + return false; + } + + // Should not start with an underscore + if username.get(0..1) == Some("_") { + return false; + } + + // Should only contain valid characters + if !username.chars().all(valid_username_character) { + return false; + } + + true +} + +#[Object] +impl UserMutations { + /// Add a user. This is only available to administrators. + async fn add_user( + &self, + ctx: &Context<'_>, + input: AddUserInput, + ) -> Result { + let state = ctx.state(); + let requester = ctx.requester(); + let clock = state.clock(); + let mut rng = state.rng(); + + if !requester.is_admin() { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let mut repo = state.repository().await?; + + if let Some(user) = repo.user().find_by_username(&input.username).await? { + return Ok(AddUserPayload::Exists(user)); + } + + // Do some basic check on the username + if !username_valid(&input.username) { + return Ok(AddUserPayload::Invalid); + } + + let user = repo.user().add(&mut rng, &clock, input.username).await?; + + repo.job() + .schedule_job(ProvisionUserJob::new(&user)) + .await?; + + repo.save().await?; + + Ok(AddUserPayload::Added(user)) + } + + /// Lock a user. This is only available to administrators. + async fn lock_user( + &self, + ctx: &Context<'_>, + input: LockUserInput, + ) -> Result { + let state = ctx.state(); + let requester = ctx.requester(); + + 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(LockUserPayload::NotFound); + }; + + let deactivate = input.deactivate.unwrap_or(false); + + let user = repo.user().lock(&state.clock(), user).await?; + + if deactivate { + info!("Scheduling deactivation of user {}", user.id); + repo.job() + .schedule_job(DeactivateUserJob::new(&user, deactivate)) + .await?; + } + + repo.save().await?; + + Ok(LockUserPayload::Locked(user)) + } +} diff --git a/crates/graphql/src/mutations/user_email.rs b/crates/graphql/src/mutations/user_email.rs index ef8c124f..3f2f7557 100644 --- a/crates/graphql/src/mutations/user_email.rs +++ b/crates/graphql/src/mutations/user_email.rs @@ -36,8 +36,15 @@ pub struct UserEmailMutations { struct AddEmailInput { /// The email address to add email: String, + /// The ID of the user to add the email address to user_id: ID, + + /// Skip the email address verification. Only allowed for admins. + skip_verification: Option, + + /// Skip the email address policy check. Only allowed for admins. + skip_policy_check: Option, } /// The status of the `addEmail` mutation @@ -382,6 +389,16 @@ impl UserEmailMutations { return Err(async_graphql::Error::new("Unauthorized")); } + // Only admins can skip validation + if (input.skip_verification.is_some() || input.skip_policy_check.is_some()) + && !requester.is_admin() + { + return Err(async_graphql::Error::new("Unauthorized")); + } + + let skip_verification = input.skip_verification.unwrap_or(false); + let skip_policy_check = input.skip_policy_check.unwrap_or(false); + let mut repo = state.repository().await?; let user = repo @@ -398,17 +415,19 @@ impl UserEmailMutations { return Ok(AddEmailPayload::Invalid); } - let mut policy = state.policy().await?; - let res = policy.evaluate_email(&input.email).await?; - if !res.valid() { - return Ok(AddEmailPayload::Denied { - violations: res.violations, - }); + if !skip_policy_check { + let mut policy = state.policy().await?; + let res = policy.evaluate_email(&input.email).await?; + if !res.valid() { + return Ok(AddEmailPayload::Denied { + violations: res.violations, + }); + } } // Find an existing email address let existing_user_email = repo.user_email().find(&user, &input.email).await?; - let (added, user_email) = if let Some(user_email) = existing_user_email { + let (added, mut user_email) = if let Some(user_email) = existing_user_email { (false, user_email) } else { let clock = state.clock(); @@ -424,9 +443,16 @@ impl UserEmailMutations { // Schedule a job to verify the email address if needed if user_email.confirmed_at.is_none() { - repo.job() - .schedule_job(VerifyEmailJob::new(&user_email)) - .await?; + if skip_verification { + user_email = repo + .user_email() + .mark_as_verified(&state.clock(), user_email) + .await?; + } else { + repo.job() + .schedule_job(VerifyEmailJob::new(&user_email)) + .await?; + } } repo.save().await?; diff --git a/crates/graphql/src/query/mod.rs b/crates/graphql/src/query/mod.rs index d0a8f6d8..024f1360 100644 --- a/crates/graphql/src/query/mod.rs +++ b/crates/graphql/src/query/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. use async_graphql::{Context, MergedObject, Object, ID}; +use mas_storage::user::UserRepository; use crate::{ model::{Anonymous, BrowserSession, Node, NodeType, OAuth2Client, User, UserEmail}, @@ -99,6 +100,30 @@ impl BaseQuery { Ok(user.map(User)) } + /// Fetch a user by its username. + async fn user_by_username( + &self, + ctx: &Context<'_>, + username: String, + ) -> Result, async_graphql::Error> { + let requester = ctx.requester(); + let state = ctx.state(); + let mut repo = state.repository().await?; + + let user = repo.user().find_by_username(&username).await?; + let Some(user) = user else { + // We don't want to leak the existence of a user + return Ok(None); + }; + + // Users can only see themselves, except for admins + if !requester.is_owner_or_admin(&user) { + return Ok(None); + } + + Ok(Some(User(user))) + } + /// Fetch a browser session by its ID. async fn browser_session( &self, diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 5a89768e..ea8d66db 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -10,6 +10,14 @@ input AddEmailInput { The ID of the user to add the email address to """ userId: ID! + """ + Skip the email address verification. Only allowed for admins. + """ + skipVerification: Boolean + """ + Skip the email address policy check. Only allowed for admins. + """ + skipPolicyCheck: Boolean } """ @@ -56,6 +64,48 @@ enum AddEmailStatus { DENIED } +""" +The input for the `addUser` mutation. +""" +input AddUserInput { + """ + The username of the user to add. + """ + username: String! +} + +""" +The payload for the `addUser` mutation. +""" +type AddUserPayload { + """ + Status of the operation + """ + status: AddUserStatus! + """ + The user that was added. + """ + user: User +} + +""" +The status of the `addUser` mutation. +""" +enum AddUserStatus { + """ + The user was added. + """ + ADDED + """ + The user already exists. + """ + EXISTS + """ + The username is invalid. + """ + INVALID +} + type Anonymous implements Node { id: ID! } @@ -439,6 +489,48 @@ enum EndOAuth2SessionStatus { NOT_FOUND } +""" +The input for the `lockUser` mutation. +""" +input LockUserInput { + """ + The ID of the user to lock. + """ + userId: ID! + """ + Permanently lock the user. + """ + deactivate: Boolean +} + +""" +The payload for the `lockUser` mutation. +""" +type LockUserPayload { + """ + Status of the operation + """ + status: LockUserStatus! + """ + The user that was locked. + """ + user: User +} + +""" +The status of the `lockUser` mutation. +""" +enum LockUserStatus { + """ + The user was locked. + """ + LOCKED + """ + The user was not found. + """ + NOT_FOUND +} + type MatrixUser { """ The Matrix ID of the user. @@ -480,6 +572,14 @@ type Mutation { Set an email address as primary """ setPrimaryEmail(input: SetPrimaryEmailInput!): SetPrimaryEmailPayload! + """ + Add a user. This is only available to administrators. + """ + addUser(input: AddUserInput!): AddUserPayload! + """ + Lock a user. This is only available to administrators. + """ + lockUser(input: LockUserInput!): LockUserPayload! endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload! endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload! endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload! @@ -685,6 +785,10 @@ type Query { """ user(id: ID!): User """ + Fetch a user by its username. + """ + userByUsername(username: String!): User + """ Fetch a browser session by its ID. """ browserSession(id: ID!): BrowserSession @@ -1027,6 +1131,14 @@ type User implements Node { """ username: String! """ + When the object was created. + """ + createdAt: DateTime! + """ + When the user was locked out. + """ + lockedAt: DateTime + """ Access to the user's Matrix account information. """ matrix: MatrixUser! diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 8e9320d4..868a22f8 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -41,6 +41,10 @@ export type Scalars = { export type AddEmailInput = { /** The email address to add */ email: Scalars["String"]["input"]; + /** Skip the email address policy check. Only allowed for admins. */ + skipPolicyCheck?: InputMaybe; + /** Skip the email address verification. Only allowed for admins. */ + skipVerification?: InputMaybe; /** The ID of the user to add the email address to */ userId: Scalars["ID"]["input"]; }; @@ -70,6 +74,31 @@ export enum AddEmailStatus { Invalid = "INVALID", } +/** The input for the `addUser` mutation. */ +export type AddUserInput = { + /** The username of the user to add. */ + username: Scalars["String"]["input"]; +}; + +/** The payload for the `addUser` mutation. */ +export type AddUserPayload = { + __typename?: "AddUserPayload"; + /** Status of the operation */ + status: AddUserStatus; + /** The user that was added. */ + user?: Maybe; +}; + +/** The status of the `addUser` mutation. */ +export enum AddUserStatus { + /** The user was added. */ + Added = "ADDED", + /** The user already exists. */ + Exists = "EXISTS", + /** The username is invalid. */ + Invalid = "INVALID", +} + export type Anonymous = Node & { __typename?: "Anonymous"; id: Scalars["ID"]["output"]; @@ -313,6 +342,31 @@ export enum EndOAuth2SessionStatus { NotFound = "NOT_FOUND", } +/** The input for the `lockUser` mutation. */ +export type LockUserInput = { + /** Permanently lock the user. */ + deactivate?: InputMaybe; + /** The ID of the user to lock. */ + userId: Scalars["ID"]["input"]; +}; + +/** The payload for the `lockUser` mutation. */ +export type LockUserPayload = { + __typename?: "LockUserPayload"; + /** Status of the operation */ + status: LockUserStatus; + /** The user that was locked. */ + user?: Maybe; +}; + +/** The status of the `lockUser` mutation. */ +export enum LockUserStatus { + /** The user was locked. */ + Locked = "LOCKED", + /** The user was not found. */ + NotFound = "NOT_FOUND", +} + export type MatrixUser = { __typename?: "MatrixUser"; /** The avatar URL of the user, if any. */ @@ -328,9 +382,13 @@ export type Mutation = { __typename?: "Mutation"; /** Add an email address to the specified user */ addEmail: AddEmailPayload; + /** Add a user. This is only available to administrators. */ + addUser: AddUserPayload; endBrowserSession: EndBrowserSessionPayload; endCompatSession: EndCompatSessionPayload; endOauth2Session: EndOAuth2SessionPayload; + /** Lock a user. This is only available to administrators. */ + lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; /** Send a verification code for an email address */ @@ -348,6 +406,11 @@ export type MutationAddEmailArgs = { input: AddEmailInput; }; +/** The mutations root of the GraphQL interface. */ +export type MutationAddUserArgs = { + input: AddUserInput; +}; + /** The mutations root of the GraphQL interface. */ export type MutationEndBrowserSessionArgs = { input: EndBrowserSessionInput; @@ -363,6 +426,11 @@ export type MutationEndOauth2SessionArgs = { input: EndOAuth2SessionInput; }; +/** The mutations root of the GraphQL interface. */ +export type MutationLockUserArgs = { + input: LockUserInput; +}; + /** The mutations root of the GraphQL interface. */ export type MutationRemoveEmailArgs = { input: RemoveEmailInput; @@ -521,6 +589,8 @@ export type Query = { upstreamOauth2Providers: UpstreamOAuth2ProviderConnection; /** Fetch a user by its ID. */ user?: Maybe; + /** Fetch a user by its username. */ + userByUsername?: Maybe; /** Fetch a user email by its ID. */ userEmail?: Maybe; /** Get the viewer */ @@ -573,6 +643,11 @@ export type QueryUserArgs = { id: Scalars["ID"]["input"]; }; +/** The query root of the GraphQL interface. */ +export type QueryUserByUsernameArgs = { + username: Scalars["String"]["input"]; +}; + /** The query root of the GraphQL interface. */ export type QueryUserEmailArgs = { id: Scalars["ID"]["input"]; @@ -761,10 +836,14 @@ export type User = Node & { compatSessions: CompatSessionConnection; /** Get the list of compatibility SSO logins, chronologically sorted */ compatSsoLogins: CompatSsoLoginConnection; + /** When the object was created. */ + createdAt: Scalars["DateTime"]["output"]; /** Get the list of emails, chronologically sorted */ emails: UserEmailConnection; /** ID of the object. */ id: Scalars["ID"]["output"]; + /** When the user was locked out. */ + lockedAt?: Maybe; /** Access to the user's Matrix account information. */ matrix: MatrixUser; /** Get the list of OAuth 2.0 sessions, chronologically sorted */ diff --git a/frontend/src/gql/schema.ts b/frontend/src/gql/schema.ts index c27b9bd3..d69b1dbe 100644 --- a/frontend/src/gql/schema.ts +++ b/frontend/src/gql/schema.ts @@ -59,6 +59,33 @@ export default { ], interfaces: [], }, + { + kind: "OBJECT", + name: "AddUserPayload", + 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: "Anonymous", @@ -782,6 +809,33 @@ export default { ], interfaces: [], }, + { + kind: "OBJECT", + name: "LockUserPayload", + 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: "MatrixUser", @@ -843,6 +897,29 @@ export default { }, ], }, + { + name: "addUser", + type: { + kind: "NON_NULL", + ofType: { + kind: "OBJECT", + name: "AddUserPayload", + ofType: null, + }, + }, + args: [ + { + name: "input", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + }, + ], + }, { name: "endBrowserSession", type: { @@ -912,6 +989,29 @@ export default { }, ], }, + { + name: "lockUser", + type: { + kind: "NON_NULL", + ofType: { + kind: "OBJECT", + name: "LockUserPayload", + ofType: null, + }, + }, + args: [ + { + name: "input", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + }, + ], + }, { name: "removeEmail", type: { @@ -1657,6 +1757,26 @@ export default { }, ], }, + { + name: "userByUsername", + type: { + kind: "OBJECT", + name: "User", + ofType: null, + }, + args: [ + { + name: "username", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + }, + ], + }, { name: "userEmail", type: { @@ -2320,6 +2440,17 @@ export default { }, ], }, + { + name: "createdAt", + type: { + kind: "NON_NULL", + ofType: { + kind: "SCALAR", + name: "Any", + }, + }, + args: [], + }, { name: "emails", type: { @@ -2379,6 +2510,14 @@ export default { }, args: [], }, + { + name: "lockedAt", + type: { + kind: "SCALAR", + name: "Any", + }, + args: [], + }, { name: "matrix", type: {