Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit be5b527

Browse files
committed
graphql: admin API to add a user, lock them, and add emails without verification
1 parent 0c267c0 commit be5b527

File tree

8 files changed

+638
-10
lines changed

8 files changed

+638
-10
lines changed

crates/graphql/src/model/users.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ impl User {
6363
&self.0.username
6464
}
6565

66+
/// When the object was created.
67+
pub async fn created_at(&self) -> DateTime<Utc> {
68+
self.0.created_at
69+
}
70+
71+
/// When the user was locked out.
72+
pub async fn locked_at(&self) -> Option<DateTime<Utc>> {
73+
self.0.locked_at
74+
}
75+
6676
/// Access to the user's Matrix account information.
6777
async fn matrix(&self, ctx: &Context<'_>) -> Result<MatrixUser, async_graphql::Error> {
6878
let state = ctx.state();

crates/graphql/src/mutations/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod browser_session;
1616
mod compat_session;
1717
mod matrix;
1818
mod oauth2_session;
19+
mod user;
1920
mod user_email;
2021

2122
use async_graphql::MergedObject;
@@ -24,6 +25,7 @@ use async_graphql::MergedObject;
2425
#[derive(Default, MergedObject)]
2526
pub struct Mutation(
2627
user_email::UserEmailMutations,
28+
user::UserMutations,
2729
oauth2_session::OAuth2SessionMutations,
2830
compat_session::CompatSessionMutations,
2931
browser_session::BrowserSessionMutations,
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Copyright 2023 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use async_graphql::{Context, Description, Enum, InputObject, Object, ID};
16+
use mas_storage::{
17+
job::{DeactivateUserJob, JobRepositoryExt, ProvisionUserJob},
18+
user::UserRepository,
19+
};
20+
use tracing::info;
21+
22+
use crate::{
23+
model::{NodeType, User},
24+
state::ContextExt,
25+
};
26+
27+
#[derive(Default)]
28+
pub struct UserMutations {
29+
_private: (),
30+
}
31+
32+
/// The input for the `addUser` mutation.
33+
#[derive(InputObject)]
34+
struct AddUserInput {
35+
/// The username of the user to add.
36+
username: String,
37+
}
38+
39+
/// The status of the `addUser` mutation.
40+
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
41+
enum AddUserStatus {
42+
/// The user was added.
43+
Added,
44+
45+
/// The user already exists.
46+
Exists,
47+
48+
/// The username is invalid.
49+
Invalid,
50+
}
51+
52+
/// The payload for the `addUser` mutation.
53+
#[derive(Description)]
54+
enum AddUserPayload {
55+
Added(mas_data_model::User),
56+
Exists(mas_data_model::User),
57+
Invalid,
58+
}
59+
60+
#[Object(use_type_description)]
61+
impl AddUserPayload {
62+
/// Status of the operation
63+
async fn status(&self) -> AddUserStatus {
64+
match self {
65+
Self::Added(_) => AddUserStatus::Added,
66+
Self::Exists(_) => AddUserStatus::Exists,
67+
Self::Invalid => AddUserStatus::Invalid,
68+
}
69+
}
70+
71+
/// The user that was added.
72+
async fn user(&self) -> Option<User> {
73+
match self {
74+
Self::Added(user) | Self::Exists(user) => Some(User(user.clone())),
75+
Self::Invalid => None,
76+
}
77+
}
78+
}
79+
80+
/// The input for the `lockUser` mutation.
81+
#[derive(InputObject)]
82+
struct LockUserInput {
83+
/// The ID of the user to lock.
84+
user_id: ID,
85+
86+
/// Permanently lock the user.
87+
deactivate: Option<bool>,
88+
}
89+
90+
/// The status of the `lockUser` mutation.
91+
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
92+
enum LockUserStatus {
93+
/// The user was locked.
94+
Locked,
95+
96+
/// The user was not found.
97+
NotFound,
98+
}
99+
100+
/// The payload for the `lockUser` mutation.
101+
#[derive(Description)]
102+
enum LockUserPayload {
103+
/// The user was locked.
104+
Locked(mas_data_model::User),
105+
106+
/// The user was not found.
107+
NotFound,
108+
}
109+
110+
#[Object(use_type_description)]
111+
impl LockUserPayload {
112+
/// Status of the operation
113+
async fn status(&self) -> LockUserStatus {
114+
match self {
115+
Self::Locked(_) => LockUserStatus::Locked,
116+
Self::NotFound => LockUserStatus::NotFound,
117+
}
118+
}
119+
120+
/// The user that was locked.
121+
async fn user(&self) -> Option<User> {
122+
match self {
123+
Self::Locked(user) => Some(User(user.clone())),
124+
Self::NotFound => None,
125+
}
126+
}
127+
}
128+
129+
fn valid_username_character(c: char) -> bool {
130+
c.is_ascii_lowercase()
131+
|| c.is_ascii_digit()
132+
|| c == '='
133+
|| c == '_'
134+
|| c == '-'
135+
|| c == '.'
136+
|| c == '/'
137+
|| c == '+'
138+
}
139+
140+
// XXX: this should probably be moved somewhere else
141+
fn username_valid(username: &str) -> bool {
142+
if username.is_empty() || username.len() > 255 {
143+
return false;
144+
}
145+
146+
// Should not start with an underscore
147+
if username.get(0..1) == Some("_") {
148+
return false;
149+
}
150+
151+
// Should only contain valid characters
152+
if !username.chars().all(valid_username_character) {
153+
return false;
154+
}
155+
156+
true
157+
}
158+
159+
#[Object]
160+
impl UserMutations {
161+
/// Add a user. This is only available to administrators.
162+
async fn add_user(
163+
&self,
164+
ctx: &Context<'_>,
165+
input: AddUserInput,
166+
) -> Result<AddUserPayload, async_graphql::Error> {
167+
let state = ctx.state();
168+
let requester = ctx.requester();
169+
let clock = state.clock();
170+
let mut rng = state.rng();
171+
172+
if !requester.is_admin() {
173+
return Err(async_graphql::Error::new("Unauthorized"));
174+
}
175+
176+
let mut repo = state.repository().await?;
177+
178+
if let Some(user) = repo.user().find_by_username(&input.username).await? {
179+
return Ok(AddUserPayload::Exists(user));
180+
}
181+
182+
// Do some basic check on the username
183+
if !username_valid(&input.username) {
184+
return Ok(AddUserPayload::Invalid);
185+
}
186+
187+
let user = repo.user().add(&mut rng, &clock, input.username).await?;
188+
189+
repo.job()
190+
.schedule_job(ProvisionUserJob::new(&user))
191+
.await?;
192+
193+
repo.save().await?;
194+
195+
Ok(AddUserPayload::Added(user))
196+
}
197+
198+
/// Lock a user. This is only available to administrators.
199+
async fn lock_user(
200+
&self,
201+
ctx: &Context<'_>,
202+
input: LockUserInput,
203+
) -> Result<LockUserPayload, async_graphql::Error> {
204+
let state = ctx.state();
205+
let requester = ctx.requester();
206+
207+
if !requester.is_admin() {
208+
return Err(async_graphql::Error::new("Unauthorized"));
209+
}
210+
211+
let mut repo = state.repository().await?;
212+
213+
let user_id = NodeType::User.extract_ulid(&input.user_id)?;
214+
let user = repo.user().lookup(user_id).await?;
215+
216+
let Some(user) = user else {
217+
return Ok(LockUserPayload::NotFound);
218+
};
219+
220+
let deactivate = input.deactivate.unwrap_or(false);
221+
222+
let user = repo.user().lock(&state.clock(), user).await?;
223+
224+
if deactivate {
225+
info!("Scheduling deactivation of user {}", user.id);
226+
repo.job()
227+
.schedule_job(DeactivateUserJob::new(&user, deactivate))
228+
.await?;
229+
}
230+
231+
repo.save().await?;
232+
233+
Ok(LockUserPayload::Locked(user))
234+
}
235+
}

crates/graphql/src/mutations/user_email.rs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,15 @@ pub struct UserEmailMutations {
3636
struct AddEmailInput {
3737
/// The email address to add
3838
email: String,
39+
3940
/// The ID of the user to add the email address to
4041
user_id: ID,
42+
43+
/// Skip the email address verification. Only allowed for admins.
44+
skip_verification: Option<bool>,
45+
46+
/// Skip the email address policy check. Only allowed for admins.
47+
skip_policy_check: Option<bool>,
4148
}
4249

4350
/// The status of the `addEmail` mutation
@@ -382,6 +389,16 @@ impl UserEmailMutations {
382389
return Err(async_graphql::Error::new("Unauthorized"));
383390
}
384391

392+
// Only admins can skip validation
393+
if (input.skip_verification.is_some() || input.skip_policy_check.is_some())
394+
&& !requester.is_admin()
395+
{
396+
return Err(async_graphql::Error::new("Unauthorized"));
397+
}
398+
399+
let skip_verification = input.skip_verification.unwrap_or(false);
400+
let skip_policy_check = input.skip_policy_check.unwrap_or(false);
401+
385402
let mut repo = state.repository().await?;
386403

387404
let user = repo
@@ -398,17 +415,19 @@ impl UserEmailMutations {
398415
return Ok(AddEmailPayload::Invalid);
399416
}
400417

401-
let mut policy = state.policy().await?;
402-
let res = policy.evaluate_email(&input.email).await?;
403-
if !res.valid() {
404-
return Ok(AddEmailPayload::Denied {
405-
violations: res.violations,
406-
});
418+
if !skip_policy_check {
419+
let mut policy = state.policy().await?;
420+
let res = policy.evaluate_email(&input.email).await?;
421+
if !res.valid() {
422+
return Ok(AddEmailPayload::Denied {
423+
violations: res.violations,
424+
});
425+
}
407426
}
408427

409428
// Find an existing email address
410429
let existing_user_email = repo.user_email().find(&user, &input.email).await?;
411-
let (added, user_email) = if let Some(user_email) = existing_user_email {
430+
let (added, mut user_email) = if let Some(user_email) = existing_user_email {
412431
(false, user_email)
413432
} else {
414433
let clock = state.clock();
@@ -424,9 +443,16 @@ impl UserEmailMutations {
424443

425444
// Schedule a job to verify the email address if needed
426445
if user_email.confirmed_at.is_none() {
427-
repo.job()
428-
.schedule_job(VerifyEmailJob::new(&user_email))
429-
.await?;
446+
if skip_verification {
447+
user_email = repo
448+
.user_email()
449+
.mark_as_verified(&state.clock(), user_email)
450+
.await?;
451+
} else {
452+
repo.job()
453+
.schedule_job(VerifyEmailJob::new(&user_email))
454+
.await?;
455+
}
430456
}
431457

432458
repo.save().await?;

crates/graphql/src/query/mod.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
use async_graphql::{Context, MergedObject, Object, ID};
16+
use mas_storage::user::UserRepository;
1617

1718
use crate::{
1819
model::{Anonymous, BrowserSession, Node, NodeType, OAuth2Client, User, UserEmail},
@@ -99,6 +100,30 @@ impl BaseQuery {
99100
Ok(user.map(User))
100101
}
101102

103+
/// Fetch a user by its username.
104+
async fn user_by_username(
105+
&self,
106+
ctx: &Context<'_>,
107+
username: String,
108+
) -> Result<Option<User>, async_graphql::Error> {
109+
let requester = ctx.requester();
110+
let state = ctx.state();
111+
let mut repo = state.repository().await?;
112+
113+
let user = repo.user().find_by_username(&username).await?;
114+
let Some(user) = user else {
115+
// We don't want to leak the existence of a user
116+
return Ok(None);
117+
};
118+
119+
// Users can only see themselves, except for admins
120+
if !requester.is_owner_or_admin(&user) {
121+
return Ok(None);
122+
}
123+
124+
Ok(Some(User(user)))
125+
}
126+
102127
/// Fetch a browser session by its ID.
103128
async fn browser_session(
104129
&self,

0 commit comments

Comments
 (0)