diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index c020fa720..41b6c4f70 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -21,14 +21,15 @@ pub struct User { pub sub: String, pub created_at: DateTime, pub locked_at: Option>, + pub deactivated_at: Option>, pub can_request_admin: bool, } impl User { - /// Returns `true` unless the user is locked. + /// Returns `true` unless the user is locked or deactivated. #[must_use] pub fn is_valid(&self) -> bool { - self.locked_at.is_none() + self.locked_at.is_none() && self.deactivated_at.is_none() } } @@ -42,6 +43,7 @@ impl User { sub: "123-456".to_owned(), created_at: now, locked_at: None, + deactivated_at: None, can_request_admin: false, }] } diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index e011214ca..bb5642036 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -327,6 +327,7 @@ mod tests { sub: "123-456".to_owned(), created_at: now, locked_at: None, + deactivated_at: None, can_request_admin: false, }; @@ -336,6 +337,7 @@ mod tests { sub: "123-456".to_owned(), created_at: now, locked_at: None, + deactivated_at: None, can_request_admin: false, }; diff --git a/crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json b/crates/storage-pg/.sqlx/query-48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455.json similarity index 69% rename from crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json rename to crates/storage-pg/.sqlx/query-48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455.json index 2a7e94117..52c7ab0bc 100644 --- a/crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json +++ b/crates/storage-pg/.sqlx/query-48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ", "describe": { "columns": [ { @@ -25,6 +25,11 @@ }, { "ordinal": 4, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, "name": "can_request_admin", "type_info": "Bool" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b" + "hash": "48213d718a256a12540c0aec595ca3e436be423f2d0c868700c6397745ed0455" } diff --git a/crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json b/crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json similarity index 69% rename from crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json rename to crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json index 82b1b659c..6603fa37d 100644 --- a/crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json +++ b/crates/storage-pg/.sqlx/query-cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , deactivated_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", "describe": { "columns": [ { @@ -25,6 +25,11 @@ }, { "ordinal": 4, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, "name": "can_request_admin", "type_info": "Bool" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356" + "hash": "cc332eda5965715607ffa4eeeacc1b6532cbd8fe49904ccdb1afe315804d348d" } diff --git a/crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json b/crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json similarity index 80% rename from crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json rename to crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json index ccadfd747..1ddb0acc8 100644 --- a/crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json +++ b/crates/storage-pg/.sqlx/query-f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", + "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.deactivated_at AS \"user_deactivated_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", "describe": { "columns": [ { @@ -55,6 +55,11 @@ }, { "ordinal": 10, + "name": "user_deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, "name": "user_can_request_admin", "type_info": "Bool" } @@ -75,8 +80,9 @@ false, false, true, + true, false ] }, - "hash": "7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962" + "hash": "f924db60febad26c9fff24881b05dd1e1f7ba288d7b2f2f8e30a1ea43e98b8c8" } diff --git a/crates/storage-pg/migrations/20250311093145_user_deactivated_at.sql b/crates/storage-pg/migrations/20250311093145_user_deactivated_at.sql new file mode 100644 index 000000000..e73e3c2ae --- /dev/null +++ b/crates/storage-pg/migrations/20250311093145_user_deactivated_at.sql @@ -0,0 +1,8 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +ALTER TABLE users + -- Track when a user was deactivated. + ADD COLUMN deactivated_at TIMESTAMP WITH TIME ZONE; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 841a4648e..89d79c90b 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -25,6 +25,7 @@ pub enum Users { Username, CreatedAt, LockedAt, + DeactivatedAt, CanRequestAdmin, } diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index a7a1773d6..2fcaf4b21 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -72,6 +72,7 @@ mod priv_ { pub(super) username: String, pub(super) created_at: DateTime, pub(super) locked_at: Option>, + pub(super) deactivated_at: Option>, pub(super) can_request_admin: bool, } } @@ -87,6 +88,7 @@ impl From for User { sub: id.to_string(), created_at: value.created_at, locked_at: value.locked_at, + deactivated_at: value.deactivated_at, can_request_admin: value.can_request_admin, } } @@ -96,10 +98,18 @@ impl Filter for UserFilter<'_> { fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition { sea_query::Condition::all() .add_option(self.state().map(|state| { - if state.is_locked() { - Expr::col((Users::Table, Users::LockedAt)).is_not_null() - } else { - Expr::col((Users::Table, Users::LockedAt)).is_null() + match state { + mas_storage::user::UserState::Deactivated => { + Expr::col((Users::Table, Users::DeactivatedAt)).is_not_null() + } + mas_storage::user::UserState::Locked => { + Expr::col((Users::Table, Users::LockedAt)).is_not_null() + } + mas_storage::user::UserState::Active => { + Expr::col((Users::Table, Users::LockedAt)) + .is_null() + .and(Expr::col((Users::Table, Users::DeactivatedAt)).is_null()) + } } })) .add_option(self.can_request_admin().map(|can_request_admin| { @@ -129,6 +139,7 @@ impl UserRepository for PgUserRepository<'_> { , username , created_at , locked_at + , deactivated_at , can_request_admin FROM users WHERE user_id = $1 @@ -161,6 +172,7 @@ impl UserRepository for PgUserRepository<'_> { , username , created_at , locked_at + , deactivated_at , can_request_admin FROM users WHERE username = $1 @@ -220,6 +232,7 @@ impl UserRepository for PgUserRepository<'_> { sub: id.to_string(), created_at, locked_at: None, + deactivated_at: None, can_request_admin: false, }) } @@ -382,6 +395,10 @@ impl UserRepository for PgUserRepository<'_> { Expr::col((Users::Table, Users::LockedAt)), UserLookupIden::LockedAt, ) + .expr_as( + Expr::col((Users::Table, Users::DeactivatedAt)), + UserLookupIden::DeactivatedAt, + ) .expr_as( Expr::col((Users::Table, Users::CanRequestAdmin)), UserLookupIden::CanRequestAdmin, diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index f90c89b89..ce027afc0 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -59,6 +59,7 @@ struct SessionLookup { user_username: String, user_created_at: DateTime, user_locked_at: Option>, + user_deactivated_at: Option>, user_can_request_admin: bool, } @@ -73,6 +74,7 @@ impl TryFrom for BrowserSession { sub: id.to_string(), created_at: value.user_created_at, locked_at: value.user_locked_at, + deactivated_at: value.user_deactivated_at, can_request_admin: value.user_can_request_admin, }; @@ -173,6 +175,7 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { , u.username AS "user_username" , u.created_at AS "user_created_at" , u.locked_at AS "user_locked_at" + , u.deactivated_at AS "user_deactivated_at" , u.can_request_admin AS "user_can_request_admin" FROM user_sessions s INNER JOIN users u @@ -356,6 +359,10 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { Expr::col((Users::Table, Users::LockedAt)), SessionLookupIden::UserLockedAt, ) + .expr_as( + Expr::col((Users::Table, Users::DeactivatedAt)), + SessionLookupIden::UserDeactivatedAt, + ) .expr_as( Expr::col((Users::Table, Users::CanRequestAdmin)), SessionLookupIden::UserCanRequestAdmin, diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 0ab559d12..b0f8ea265 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -32,6 +32,9 @@ pub use self::{ /// The state of a user account #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum UserState { + /// The account is deactivated, it has the `deactivated_at` timestamp set + Deactivated, + /// The account is locked, it has the `locked_at` timestamp set Locked, @@ -48,6 +51,14 @@ impl UserState { matches!(self, Self::Locked) } + /// Returns `true` if the user state is [`Deactivated`]. + /// + /// [`Deactivated`]: UserState::Deactivated + #[must_use] + pub fn is_deactivated(&self) -> bool { + matches!(self, Self::Deactivated) + } + /// Returns `true` if the user state is [`Active`]. /// /// [`Active`]: UserState::Active @@ -86,6 +97,13 @@ impl UserFilter<'_> { self } + /// Filter for deactivated users + #[must_use] + pub fn deactivated_only(mut self) -> Self { + self.state = Some(UserState::Deactivated); + self + } + /// Filter for users that can request admin privileges #[must_use] pub fn can_request_admin_only(mut self) -> Self {