Add a deactivated_at flag on users

This commit is contained in:
Quentin Gliech
2025-03-11 11:37:32 +01:00
parent db417dd12e
commit 9c35f18d79
10 changed files with 85 additions and 12 deletions

View File

@@ -21,14 +21,15 @@ pub struct User {
pub sub: String,
pub created_at: DateTime<Utc>,
pub locked_at: Option<DateTime<Utc>>,
pub deactivated_at: Option<DateTime<Utc>>,
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,
}]
}

View File

@@ -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,
};

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -25,6 +25,7 @@ pub enum Users {
Username,
CreatedAt,
LockedAt,
DeactivatedAt,
CanRequestAdmin,
}

View File

@@ -72,6 +72,7 @@ mod priv_ {
pub(super) username: String,
pub(super) created_at: DateTime<Utc>,
pub(super) locked_at: Option<DateTime<Utc>>,
pub(super) deactivated_at: Option<DateTime<Utc>>,
pub(super) can_request_admin: bool,
}
}
@@ -87,6 +88,7 @@ impl From<UserLookup> 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,

View File

@@ -59,6 +59,7 @@ struct SessionLookup {
user_username: String,
user_created_at: DateTime<Utc>,
user_locked_at: Option<DateTime<Utc>>,
user_deactivated_at: Option<DateTime<Utc>>,
user_can_request_admin: bool,
}
@@ -73,6 +74,7 @@ impl TryFrom<SessionLookup> 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,

View File

@@ -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 {