Skip to main content

mas_storage_pg/user/
mod.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8//! A module containing the PostgreSQL implementation of the user-related
9//! repositories
10
11use async_trait::async_trait;
12use mas_data_model::{Clock, UlidExt as _, User};
13use mas_storage::user::{UserFilter, UserRepository};
14use rand::Rng;
15use sea_query::{
16    Expr, ExprTrait, PostgresQueryBuilder, Query, SimpleExpr, extension::postgres::PgExpr as _,
17};
18use sea_query_sqlx::SqlxBinder;
19use sqlx::PgConnection;
20use ulid::Ulid;
21use uuid::Uuid;
22
23use crate::{
24    DatabaseError,
25    filter::{Filter, StatementExt},
26    iden::{CompatSessions, OAuth2Sessions, Users},
27    pagination::QueryBuilderExt,
28    tracing::ExecuteExt,
29};
30
31mod email;
32mod password;
33mod recovery;
34mod registration;
35mod registration_token;
36mod session;
37mod terms;
38
39#[cfg(test)]
40mod tests;
41
42pub use self::{
43    email::PgUserEmailRepository, password::PgUserPasswordRepository,
44    recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository,
45    registration_token::PgUserRegistrationTokenRepository, session::PgBrowserSessionRepository,
46    terms::PgUserTermsRepository,
47};
48
49/// An implementation of [`UserRepository`] for a PostgreSQL connection
50pub struct PgUserRepository<'c> {
51    conn: &'c mut PgConnection,
52}
53
54impl<'c> PgUserRepository<'c> {
55    /// Create a new [`PgUserRepository`] from an active PostgreSQL connection
56    pub fn new(conn: &'c mut PgConnection) -> Self {
57        Self { conn }
58    }
59}
60
61mod priv_ {
62    // The enum_def macro generates a public enum, which we don't want, because it
63    // triggers the missing docs warning
64    #![allow(missing_docs)]
65
66    use chrono::{DateTime, Utc};
67    use mas_storage::pagination::Node;
68    use sea_query::enum_def;
69    use ulid::Ulid;
70    use uuid::Uuid;
71
72    #[derive(Debug, Clone, sqlx::FromRow)]
73    #[enum_def]
74    pub(super) struct UserLookup {
75        pub(super) user_id: Uuid,
76        pub(super) username: String,
77        pub(super) created_at: DateTime<Utc>,
78        pub(super) locked_at: Option<DateTime<Utc>>,
79        pub(super) deactivated_at: Option<DateTime<Utc>>,
80        pub(super) can_request_admin: bool,
81        pub(super) is_guest: bool,
82    }
83
84    impl Node<Ulid> for UserLookup {
85        fn cursor(&self) -> Ulid {
86            self.user_id.into()
87        }
88    }
89}
90
91use priv_::{UserLookup, UserLookupIden};
92
93impl From<UserLookup> for User {
94    fn from(value: UserLookup) -> Self {
95        let id = value.user_id.into();
96        Self {
97            id,
98            username: value.username,
99            sub: id.to_string(),
100            created_at: value.created_at,
101            locked_at: value.locked_at,
102            deactivated_at: value.deactivated_at,
103            can_request_admin: value.can_request_admin,
104            is_guest: value.is_guest,
105        }
106    }
107}
108
109impl Filter for UserFilter<'_> {
110    fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
111        sea_query::Condition::all()
112            .add_option(self.state().map(|state| {
113                match state {
114                    mas_storage::user::UserState::Deactivated => {
115                        Expr::col((Users::Table, Users::DeactivatedAt)).is_not_null()
116                    }
117                    mas_storage::user::UserState::Locked => {
118                        Expr::col((Users::Table, Users::LockedAt)).is_not_null()
119                    }
120                    mas_storage::user::UserState::Active => {
121                        Expr::col((Users::Table, Users::LockedAt))
122                            .is_null()
123                            .and(Expr::col((Users::Table, Users::DeactivatedAt)).is_null())
124                    }
125                }
126            }))
127            .add_option(self.can_request_admin().map(|can_request_admin| {
128                Expr::col((Users::Table, Users::CanRequestAdmin)).eq(can_request_admin)
129            }))
130            .add_option(
131                self.is_guest()
132                    .map(|is_guest| Expr::col((Users::Table, Users::IsGuest)).eq(is_guest)),
133            )
134            .add_option(self.search().map(|search| {
135                Expr::col((Users::Table, Users::Username)).ilike(format!("%{search}%"))
136            }))
137            .add_option(self.active_oauth2_session_for_any_of_clients().map(
138                |clients| -> SimpleExpr {
139                    let client_ids: Vec<SimpleExpr> =
140                        clients.iter().map(|c| Expr::val(Uuid::from(*c))).collect();
141                    Expr::exists(
142                        Query::select()
143                            .expr(Expr::cust("1"))
144                            .from(OAuth2Sessions::Table)
145                            .and_where(
146                                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId))
147                                    .equals((Users::Table, Users::UserId)),
148                            )
149                            .and_where(
150                                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::FinishedAt))
151                                    .is_null(),
152                            )
153                            .and_where(
154                                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
155                                    .is_in(client_ids),
156                            )
157                            .take(),
158                    )
159                },
160            ))
161            .add_option(self.has_active_oauth2_session().map(|has| -> SimpleExpr {
162                let exists = Expr::exists(
163                    Query::select()
164                        .expr(Expr::cust("1"))
165                        .from(OAuth2Sessions::Table)
166                        .and_where(
167                            Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId))
168                                .equals((Users::Table, Users::UserId)),
169                        )
170                        .and_where(
171                            Expr::col((OAuth2Sessions::Table, OAuth2Sessions::FinishedAt))
172                                .is_null(),
173                        )
174                        .take(),
175                );
176                if has { exists } else { exists.not() }
177            }))
178            .add_option(self.has_active_compat_session().map(|has| -> SimpleExpr {
179                let exists = Expr::exists(
180                    Query::select()
181                        .expr(Expr::cust("1"))
182                        .from(CompatSessions::Table)
183                        .and_where(
184                            Expr::col((CompatSessions::Table, CompatSessions::UserId))
185                                .equals((Users::Table, Users::UserId)),
186                        )
187                        .and_where(
188                            Expr::col((CompatSessions::Table, CompatSessions::FinishedAt))
189                                .is_null(),
190                        )
191                        .take(),
192                );
193                if has { exists } else { exists.not() }
194            }))
195    }
196}
197
198#[async_trait]
199impl UserRepository for PgUserRepository<'_> {
200    type Error = DatabaseError;
201
202    #[tracing::instrument(
203        name = "db.user.lookup",
204        skip_all,
205        fields(
206            db.query.text,
207            user.id = %id,
208        ),
209    )]
210    async fn lookup(&mut self, id: Ulid) -> Result<Option<User>, Self::Error> {
211        let res = sqlx::query_as!(
212            UserLookup,
213            r#"
214                SELECT user_id
215                     , username
216                     , created_at
217                     , locked_at
218                     , deactivated_at
219                     , can_request_admin
220                     , is_guest
221                FROM users
222                WHERE user_id = $1
223            "#,
224            Uuid::from(id),
225        )
226        .traced()
227        .fetch_optional(&mut *self.conn)
228        .await?;
229
230        let Some(res) = res else { return Ok(None) };
231
232        Ok(Some(res.into()))
233    }
234
235    #[tracing::instrument(
236        name = "db.user.find_by_username",
237        skip_all,
238        fields(
239            db.query.text,
240            user.username = username,
241        ),
242    )]
243    async fn find_by_username(&mut self, username: &str) -> Result<Option<User>, Self::Error> {
244        // We may have multiple users with the same username, but with a different
245        // casing. In this case, we want to return the one which matches the exact
246        // casing
247        let res = sqlx::query_as!(
248            UserLookup,
249            r#"
250                SELECT user_id
251                     , username
252                     , created_at
253                     , locked_at
254                     , deactivated_at
255                     , can_request_admin
256                     , is_guest
257                FROM users
258                WHERE LOWER(username) = LOWER($1)
259            "#,
260            username,
261        )
262        .traced()
263        .fetch_all(&mut *self.conn)
264        .await?;
265
266        match &res[..] {
267            // Happy path: there is only one user matching the username…
268            [user] => Ok(Some(user.clone().into())),
269            // …or none.
270            [] => Ok(None),
271            list => {
272                // If there are multiple users with the same username, we want to
273                // return the one which matches the exact casing
274                if let Some(user) = list.iter().find(|user| user.username == username) {
275                    Ok(Some(user.clone().into()))
276                } else {
277                    // If none match exactly, we prefer to return nothing
278                    Ok(None)
279                }
280            }
281        }
282    }
283
284    #[tracing::instrument(
285        name = "db.user.add",
286        skip_all,
287        fields(
288            db.query.text,
289            user.username = username,
290            user.id,
291        ),
292    )]
293    async fn add(
294        &mut self,
295        rng: &mut (dyn Rng + Send),
296        clock: &dyn Clock,
297        username: String,
298    ) -> Result<User, Self::Error> {
299        let created_at = clock.now();
300        let id = Ulid::from_datetime_with_rng(created_at, rng);
301        tracing::Span::current().record("user.id", tracing::field::display(id));
302
303        let res = sqlx::query!(
304            r#"
305                INSERT INTO users (user_id, username, created_at)
306                VALUES ($1, $2, $3)
307                ON CONFLICT (username) DO NOTHING
308            "#,
309            Uuid::from(id),
310            username,
311            created_at,
312        )
313        .traced()
314        .execute(&mut *self.conn)
315        .await?;
316
317        // If the user already exists, want to return an error but not poison the
318        // transaction
319        DatabaseError::ensure_affected_rows(&res, 1)?;
320
321        Ok(User {
322            id,
323            username,
324            sub: id.to_string(),
325            created_at,
326            locked_at: None,
327            deactivated_at: None,
328            can_request_admin: false,
329            is_guest: false,
330        })
331    }
332
333    #[tracing::instrument(
334        name = "db.user.exists",
335        skip_all,
336        fields(
337            db.query.text,
338            user.username = username,
339        ),
340    )]
341    async fn exists(&mut self, username: &str) -> Result<bool, Self::Error> {
342        let exists = sqlx::query_scalar!(
343            r#"
344                SELECT EXISTS(
345                    SELECT 1 FROM users WHERE LOWER(username) = LOWER($1)
346                ) AS "exists!"
347            "#,
348            username
349        )
350        .traced()
351        .fetch_one(&mut *self.conn)
352        .await?;
353
354        Ok(exists)
355    }
356
357    #[tracing::instrument(
358        name = "db.user.lock",
359        skip_all,
360        fields(
361            db.query.text,
362            %user.id,
363        ),
364    )]
365    async fn lock(&mut self, clock: &dyn Clock, mut user: User) -> Result<User, Self::Error> {
366        if user.locked_at.is_some() {
367            return Ok(user);
368        }
369
370        let locked_at = clock.now();
371        let res = sqlx::query!(
372            r#"
373                UPDATE users
374                SET locked_at = $1
375                WHERE user_id = $2
376            "#,
377            locked_at,
378            Uuid::from(user.id),
379        )
380        .traced()
381        .execute(&mut *self.conn)
382        .await?;
383
384        DatabaseError::ensure_affected_rows(&res, 1)?;
385
386        user.locked_at = Some(locked_at);
387
388        Ok(user)
389    }
390
391    #[tracing::instrument(
392        name = "db.user.unlock",
393        skip_all,
394        fields(
395            db.query.text,
396            %user.id,
397        ),
398    )]
399    async fn unlock(&mut self, mut user: User) -> Result<User, Self::Error> {
400        if user.locked_at.is_none() {
401            return Ok(user);
402        }
403
404        let res = sqlx::query!(
405            r#"
406                UPDATE users
407                SET locked_at = NULL
408                WHERE user_id = $1
409            "#,
410            Uuid::from(user.id),
411        )
412        .traced()
413        .execute(&mut *self.conn)
414        .await?;
415
416        DatabaseError::ensure_affected_rows(&res, 1)?;
417
418        user.locked_at = None;
419
420        Ok(user)
421    }
422
423    #[tracing::instrument(
424        name = "db.user.deactivate",
425        skip_all,
426        fields(
427            db.query.text,
428            %user.id,
429        ),
430    )]
431    async fn deactivate(&mut self, clock: &dyn Clock, mut user: User) -> Result<User, Self::Error> {
432        if user.deactivated_at.is_some() {
433            return Ok(user);
434        }
435
436        let deactivated_at = clock.now();
437        let res = sqlx::query!(
438            r#"
439                UPDATE users
440                SET deactivated_at = $2
441                WHERE user_id = $1
442                  AND deactivated_at IS NULL
443            "#,
444            Uuid::from(user.id),
445            deactivated_at,
446        )
447        .traced()
448        .execute(&mut *self.conn)
449        .await?;
450
451        DatabaseError::ensure_affected_rows(&res, 1)?;
452
453        user.deactivated_at = Some(deactivated_at);
454
455        Ok(user)
456    }
457
458    #[tracing::instrument(
459        name = "db.user.reactivate",
460        skip_all,
461        fields(
462            db.query.text,
463            %user.id,
464        ),
465    )]
466    async fn reactivate(&mut self, mut user: User) -> Result<User, Self::Error> {
467        if user.deactivated_at.is_none() {
468            return Ok(user);
469        }
470
471        let res = sqlx::query!(
472            r#"
473                UPDATE users
474                SET deactivated_at = NULL
475                WHERE user_id = $1
476            "#,
477            Uuid::from(user.id),
478        )
479        .traced()
480        .execute(&mut *self.conn)
481        .await?;
482
483        DatabaseError::ensure_affected_rows(&res, 1)?;
484
485        user.deactivated_at = None;
486
487        Ok(user)
488    }
489
490    #[tracing::instrument(
491        name = "db.user.delete_unsupported_threepids",
492        skip_all,
493        fields(
494            db.query.text,
495            %user.id,
496        ),
497    )]
498    async fn delete_unsupported_threepids(&mut self, user: &User) -> Result<usize, Self::Error> {
499        let res = sqlx::query!(
500            r#"
501                DELETE FROM user_unsupported_third_party_ids
502                WHERE user_id = $1
503            "#,
504            Uuid::from(user.id),
505        )
506        .traced()
507        .execute(&mut *self.conn)
508        .await?;
509
510        Ok(res.rows_affected().try_into().unwrap_or(usize::MAX))
511    }
512
513    #[tracing::instrument(
514        name = "db.user.set_can_request_admin",
515        skip_all,
516        fields(
517            db.query.text,
518            %user.id,
519            user.can_request_admin = can_request_admin,
520        ),
521    )]
522    async fn set_can_request_admin(
523        &mut self,
524        mut user: User,
525        can_request_admin: bool,
526    ) -> Result<User, Self::Error> {
527        let res = sqlx::query!(
528            r#"
529                UPDATE users
530                SET can_request_admin = $2
531                WHERE user_id = $1
532            "#,
533            Uuid::from(user.id),
534            can_request_admin,
535        )
536        .traced()
537        .execute(&mut *self.conn)
538        .await?;
539
540        DatabaseError::ensure_affected_rows(&res, 1)?;
541
542        user.can_request_admin = can_request_admin;
543
544        Ok(user)
545    }
546
547    #[tracing::instrument(
548        name = "db.user.list",
549        skip_all,
550        fields(
551            db.query.text,
552        ),
553    )]
554    async fn list(
555        &mut self,
556        filter: UserFilter<'_>,
557        pagination: mas_storage::Pagination,
558    ) -> Result<mas_storage::Page<User>, Self::Error> {
559        let (sql, arguments) = Query::select()
560            .expr_as(
561                Expr::col((Users::Table, Users::UserId)),
562                UserLookupIden::UserId,
563            )
564            .expr_as(
565                Expr::col((Users::Table, Users::Username)),
566                UserLookupIden::Username,
567            )
568            .expr_as(
569                Expr::col((Users::Table, Users::CreatedAt)),
570                UserLookupIden::CreatedAt,
571            )
572            .expr_as(
573                Expr::col((Users::Table, Users::LockedAt)),
574                UserLookupIden::LockedAt,
575            )
576            .expr_as(
577                Expr::col((Users::Table, Users::DeactivatedAt)),
578                UserLookupIden::DeactivatedAt,
579            )
580            .expr_as(
581                Expr::col((Users::Table, Users::CanRequestAdmin)),
582                UserLookupIden::CanRequestAdmin,
583            )
584            .expr_as(
585                Expr::col((Users::Table, Users::IsGuest)),
586                UserLookupIden::IsGuest,
587            )
588            .from(Users::Table)
589            .apply_filter(filter)
590            .generate_pagination((Users::Table, Users::UserId), pagination)
591            .build_sqlx(PostgresQueryBuilder);
592
593        let edges: Vec<UserLookup> = sqlx::query_as_with(&sql, arguments)
594            .traced()
595            .fetch_all(&mut *self.conn)
596            .await?;
597
598        let page = pagination.process(edges).map(User::from);
599
600        Ok(page)
601    }
602
603    #[tracing::instrument(
604        name = "db.user.count",
605        skip_all,
606        fields(
607            db.query.text,
608        ),
609    )]
610    async fn count(&mut self, filter: UserFilter<'_>) -> Result<usize, Self::Error> {
611        let (sql, arguments) = Query::select()
612            .expr(Expr::col((Users::Table, Users::UserId)).count())
613            .from(Users::Table)
614            .apply_filter(filter)
615            .build_sqlx(PostgresQueryBuilder);
616
617        let count: i64 = sqlx::query_scalar_with(&sql, arguments)
618            .traced()
619            .fetch_one(&mut *self.conn)
620            .await?;
621
622        count
623            .try_into()
624            .map_err(DatabaseError::to_invalid_operation)
625    }
626
627    #[tracing::instrument(
628        name = "db.user.acquire_lock_for_sync",
629        skip_all,
630        fields(
631            db.query.text,
632            user.id = %user.id,
633        ),
634    )]
635    async fn acquire_lock_for_sync(&mut self, user: &User) -> Result<(), Self::Error> {
636        // XXX: this lock isn't stictly scoped to users, but as we don't use many
637        // postgres advisory locks, it's fine for now. Later on, we could use row-level
638        // locks to make sure we don't get into trouble
639
640        // Convert the user ID to a u128 and grab the lower 64 bits
641        // As this includes 64bit of the random part of the ULID, it should be random
642        // enough to not collide
643        let lock_id = (u128::from(user.id) & 0xffff_ffff_ffff_ffff) as i64;
644
645        // Use a PG advisory lock, which will be released when the transaction is
646        // committed or rolled back
647        sqlx::query!(
648            r#"
649                SELECT pg_advisory_xact_lock($1)
650            "#,
651            lock_id,
652        )
653        .traced()
654        .execute(&mut *self.conn)
655        .await?;
656
657        Ok(())
658    }
659}