server: Add modifyTimestamp and pwdChangedTime attributes (#1265)

Add a modifyTimestamp attribute to LDAP entries for users and groups, and expose pwdChangedTime for users.
These attributes let clients track when an entry (or its password) was last changed.

 -  modifyTimestamp is a server-maintained attribute that updates on any write to user or group entries, including membership changes (on the group side).

 -  pwdChangedTime is set when a user’s password is created or changed.
This commit is contained in:
Kumpelinus
2025-08-31 14:56:07 +02:00
committed by GitHub
parent 267f08f479
commit 89cb59919b
21 changed files with 295 additions and 24 deletions

View File

@@ -206,6 +206,7 @@ impl GroupBackendHandler for SqlBackendHandler {
lowercase_display_name: Set(lower_display_name),
creation_date: Set(now),
uuid: Set(uuid),
modified_date: Set(now),
..Default::default()
};
Ok(self
@@ -268,10 +269,12 @@ impl SqlBackendHandler {
.display_name
.as_ref()
.map(|s| s.as_str().to_lowercase());
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(request.group_id),
display_name: request.display_name.map(Set).unwrap_or_default(),
lowercase_display_name: lower_display_name.map(Set).unwrap_or_default(),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;

View File

@@ -27,6 +27,8 @@ pub enum Users {
TotpSecret,
MfaType,
Uuid,
ModifiedDate,
PasswordModifiedDate,
}
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
@@ -37,6 +39,7 @@ pub(crate) enum Groups {
LowercaseDisplayName,
CreationDate,
Uuid,
ModifiedDate,
}
#[derive(DeriveIden, Clone, Copy)]
@@ -1112,6 +1115,53 @@ async fn migrate_to_v10(transaction: DatabaseTransaction) -> Result<DatabaseTran
Ok(transaction)
}
async fn migrate_to_v11(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
let builder = transaction.get_database_backend();
// Add modified_date to users table
transaction
.execute(
builder.build(
Table::alter().table(Users::Table).add_column(
ColumnDef::new(Users::ModifiedDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
),
),
)
.await?;
// Add password_modified_date to users table
transaction
.execute(
builder.build(
Table::alter().table(Users::Table).add_column(
ColumnDef::new(Users::PasswordModifiedDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
),
),
)
.await?;
// Add modified_date to groups table
transaction
.execute(
builder.build(
Table::alter().table(Groups::Table).add_column(
ColumnDef::new(Groups::ModifiedDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
),
),
)
.await?;
Ok(transaction)
}
// This is needed to make an array of async functions.
macro_rules! to_sync {
($l:ident) => {
@@ -1142,6 +1192,7 @@ pub(crate) async fn migrate_from_version(
to_sync!(migrate_to_v8),
to_sync!(migrate_to_v9),
to_sync!(migrate_to_v10),
to_sync!(migrate_to_v11),
];
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
for migration in 2..=last_version.0 {

View File

@@ -197,9 +197,12 @@ impl OpaqueHandler for SqlOpaqueHandler {
let password_file =
opaque::server::registration::get_password_file(request.registration_upload);
// Set the user password to the new password.
let now = chrono::Utc::now().naive_utc();
let user_update = model::users::ActiveModel {
user_id: ActiveValue::Set(username.clone()),
password_hash: ActiveValue::Set(Some(password_file.serialize())),
password_modified_date: ActiveValue::Set(now),
modified_date: ActiveValue::Set(now),
..Default::default()
};
user_update.update(&self.sql_pool).await?;

View File

@@ -9,7 +9,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
pub struct SchemaVersion(pub i16);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(10);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(11);
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
pub struct PrivateKeyHash(pub [u8; 32]);

View File

@@ -190,11 +190,13 @@ impl SqlBackendHandler {
request: UpdateUserRequest,
) -> Result<()> {
let lower_email = request.email.as_ref().map(|s| s.as_str().to_lowercase());
let now = chrono::Utc::now().naive_utc();
let update_user = model::users::ActiveModel {
user_id: ActiveValue::Set(request.user_id.clone()),
email: request.email.map(ActiveValue::Set).unwrap_or_default(),
lowercase_email: lower_email.map(ActiveValue::Set).unwrap_or_default(),
display_name: to_value(&request.display_name),
modified_date: ActiveValue::Set(now),
..Default::default()
};
let mut update_user_attributes = Vec::new();
@@ -325,6 +327,8 @@ impl UserBackendHandler for SqlBackendHandler {
display_name: to_value(&request.display_name),
creation_date: ActiveValue::Set(now),
uuid: ActiveValue::Set(uuid),
modified_date: ActiveValue::Set(now),
password_modified_date: ActiveValue::Set(now),
..Default::default()
};
let mut new_user_attributes = Vec::new();
@@ -391,24 +395,70 @@ impl UserBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", err, fields(user_id = ?user_id.as_str(), group_id))]
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
let new_membership = model::memberships::ActiveModel {
user_id: ActiveValue::Set(user_id.clone()),
group_id: ActiveValue::Set(group_id),
};
new_membership.insert(&self.sql_pool).await?;
let user_id = user_id.clone();
self.sql_pool
.transaction::<_, _, sea_orm::DbErr>(|transaction| {
Box::pin(async move {
let new_membership = model::memberships::ActiveModel {
user_id: ActiveValue::Set(user_id),
group_id: ActiveValue::Set(group_id),
};
new_membership.insert(transaction).await?;
// Update group modification time
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(group_id),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;
Ok(())
})
})
.await?;
Ok(())
}
#[instrument(skip_all, level = "debug", err, fields(user_id = ?user_id.as_str(), group_id))]
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
let res = model::Membership::delete_by_id((user_id.clone(), group_id))
.exec(&self.sql_pool)
.await?;
if res.rows_affected == 0 {
return Err(DomainError::EntityNotFound(format!(
"No such membership: '{user_id}' -> {group_id:?}"
)));
}
let user_id = user_id.clone();
self.sql_pool
.transaction::<_, _, sea_orm::DbErr>(|transaction| {
Box::pin(async move {
let res = model::Membership::delete_by_id((user_id.clone(), group_id))
.exec(transaction)
.await?;
if res.rows_affected == 0 {
return Err(sea_orm::DbErr::Custom(format!(
"No such membership: '{user_id}' -> {group_id:?}"
)));
}
// Update group modification time
let now = chrono::Utc::now().naive_utc();
let update_group = model::groups::ActiveModel {
group_id: Set(group_id),
modified_date: Set(now),
..Default::default()
};
update_group.update(transaction).await?;
Ok(())
})
})
.await
.map_err(|e| match e {
sea_orm::TransactionError::Connection(sea_orm::DbErr::Custom(msg)) => {
DomainError::EntityNotFound(msg)
}
sea_orm::TransactionError::Transaction(sea_orm::DbErr::Custom(msg)) => {
DomainError::EntityNotFound(msg)
}
sea_orm::TransactionError::Connection(e) => DomainError::DatabaseError(e),
sea_orm::TransactionError::Transaction(e) => DomainError::DatabaseError(e),
})?;
Ok(())
}
}