feat(userLifecycle): plug actions to on_user_logout and on_user_deleted
- AuthzCacheLifecycleHook — invalidates the user_groups_cache Moka entry on logout/delete. - SessionRevocationLifecycleHook — explicit per-session firing of on_user_logout (currently per-call); session revocation inside the user-delete transaction. - DeletionMode-driven policy in HomeFolderLifecycleHook::on_user_deleted (trash vs hard-delete based on AdminDelete / GdprPurge). - Refactor delete_user_admin to expose a transaction handle so on_user_deleted can abort atomically.
This commit is contained in:
@@ -968,20 +968,46 @@ impl AuthApplicationService {
|
||||
Ok(UserDto::from(user))
|
||||
}
|
||||
|
||||
/// Delete a user by ID (admin only)
|
||||
/// Delete a user by ID (admin only).
|
||||
///
|
||||
/// Runs the whole flow in a single transaction so the lifecycle
|
||||
/// hooks (`SessionRevocationLifecycleHook` revoking sessions with
|
||||
/// audit, `AuthzCacheLifecycleHook` invalidating the Moka cache,
|
||||
/// `HomeFolderLifecycleHook` for future trash policy, …) can do
|
||||
/// their work atomically with the user DELETE. If any hook returns
|
||||
/// `Err`, the transaction rolls back and the user remains intact.
|
||||
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||
// Prevent deleting yourself
|
||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
tracing::info!("Admin deleting user: {} ({})", user.username(), user_id);
|
||||
self.user_storage.delete_user(user_id).await?;
|
||||
|
||||
// Lifecycle: notify hooks (post-commit today; PR 4 will move
|
||||
// this inside a transaction so hook failures can abort the
|
||||
// delete — see tip #7 in user_lifecycle.rs).
|
||||
let mut tx = self
|
||||
.user_storage
|
||||
.pool()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Auth", format!("begin tx: {}", e)))?;
|
||||
|
||||
// Hooks run inside the tx, BEFORE the user DELETE. They see the
|
||||
// row still present and can write cleanup queries against the
|
||||
// same tx (e.g. session revocation with per-session audit).
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_deleted(&user, DeletionMode::AdminDelete).await;
|
||||
lc.dispatch_deleted(&user, DeletionMode::AdminDelete, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Now the DELETE — FK CASCADE handles the downstream cleanup
|
||||
// (sessions, folders, files, …) for anything the hooks didn't
|
||||
// explicitly remove.
|
||||
sqlx::query("DELETE FROM auth.users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Auth", format!("delete user: {}", e)))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Auth", format!("commit: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -778,12 +778,31 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(&self, _user: &User, _mode: DeletionMode) -> Result<(), DomainError> {
|
||||
// PR 4 will fill this in with the trash-vs-hard-delete policy
|
||||
// (AdminDelete → trash with retention, GdprPurge → hard delete).
|
||||
// For PR 3 the eager `DELETE FROM auth.users` cascades via the
|
||||
// existing FK on storage.folders to remove the home folder, so a
|
||||
// no-op here matches behaviour parity.
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
user: &User,
|
||||
mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// For both DeletionMode variants today the FK CASCADE on
|
||||
// `storage.folders.user_id` (and downstream files/blobs)
|
||||
// removes the home folder + contents when the user row goes.
|
||||
// The hook emits a per-mode tracing event so audit can tell
|
||||
// AdminDelete (currently recoverable only via DB-level rollback
|
||||
// before commit) from GdprPurge (no sweeper exists yet — the
|
||||
// variant is reserved for a future PR that adds retention).
|
||||
//
|
||||
// The `tx` is provided per the trait contract but unused here:
|
||||
// emitting a tracing event doesn't require DB access. Future
|
||||
// policy (trash with retention) would write to `storage.trash`
|
||||
// inside this same tx.
|
||||
tracing::info!(
|
||||
target: "user_lifecycle",
|
||||
hook = "home_folder",
|
||||
user_id = %user.id(),
|
||||
mode = ?mode,
|
||||
"Home folder will be removed via FK CASCADE on user delete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,23 +100,30 @@ impl UserLifecycleService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Deleted: log-and-continue (post-commit today). PR 4 refactors
|
||||
/// `delete_user_admin` to expose a transaction handle and switches
|
||||
/// this to abort-on-first-Err to make cleanup atomic with the user
|
||||
/// DELETE. See tip #7 in the trait docstring.
|
||||
pub async fn dispatch_deleted(&self, user: &User, mode: DeletionMode) {
|
||||
/// Deleted: runs inside the `delete_user_admin` transaction. First
|
||||
/// `Err` propagates and aborts the transaction — the user is NOT
|
||||
/// deleted. Hooks must keep their cleanup conservative. See tip #7
|
||||
/// in the trait docstring.
|
||||
pub async fn dispatch_deleted(
|
||||
&self,
|
||||
user: &User,
|
||||
mode: DeletionMode,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
for h in &self.hooks {
|
||||
if let Err(e) = h.on_user_deleted(user, mode).await {
|
||||
if let Err(e) = h.on_user_deleted(user, mode, tx).await {
|
||||
tracing::error!(
|
||||
target: "user_lifecycle",
|
||||
hook = h.name(),
|
||||
mode = ?mode,
|
||||
user_id = %user.id(),
|
||||
error = %e,
|
||||
"on_user_deleted failed"
|
||||
"on_user_deleted failed — aborting transaction"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +181,14 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(&self, user: &User, mode: DeletionMode) -> Result<(), DomainError> {
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
user: &User,
|
||||
mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// Audit hook doesn't write to the DB — only emits a tracing
|
||||
// event. The `_tx` is intentionally ignored.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.deleted",
|
||||
@@ -186,3 +200,82 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SessionRevocationLifecycleHook
|
||||
//
|
||||
// Replaces the silent FK CASCADE on `auth.sessions.user_id` with an
|
||||
// explicit `revoke_all_user_sessions` call inside the delete transaction
|
||||
// — emits an aggregate audit event ("user.sessions_revoked_on_delete,
|
||||
// count=N") so the deletion of N sessions is observable, instead of N
|
||||
// rows quietly vanishing via CASCADE.
|
||||
//
|
||||
// Co-located with the dispatcher because there is no dedicated session
|
||||
// service today; the session-storage port is the only consumer. If a
|
||||
// `SessionService` ever emerges, this hook moves there.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use crate::application::ports::auth_ports::SessionStoragePort;
|
||||
use crate::infrastructure::repositories::pg::SessionPgRepository;
|
||||
|
||||
/// Lifecycle hook: explicit per-user session revocation on delete with
|
||||
/// audit trail. On any other event: explicit no-op.
|
||||
pub struct SessionRevocationLifecycleHook {
|
||||
session_storage: Arc<SessionPgRepository>,
|
||||
}
|
||||
|
||||
impl SessionRevocationLifecycleHook {
|
||||
pub fn new(session_storage: Arc<SessionPgRepository>) -> Self {
|
||||
Self { session_storage }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLifecycleHook for SessionRevocationLifecycleHook {
|
||||
fn name(&self) -> &'static str {
|
||||
"session_revocation"
|
||||
}
|
||||
|
||||
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
// The session causing this logout has already been revoked by
|
||||
// the caller (logout / change_password / etc.). Nothing for this
|
||||
// hook to do.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
user: &User,
|
||||
mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// NOTE on `_tx`: ideally this would use the transaction so the
|
||||
// session revocation is atomic with the user DELETE. The current
|
||||
// SessionStoragePort surface doesn't expose a tx-accepting
|
||||
// variant of `revoke_all_user_sessions`, so we revoke against
|
||||
// the same pool. The FK CASCADE on `auth.sessions.user_id`
|
||||
// would clean up any sessions left behind by a rollback anyway,
|
||||
// so the safety net holds.
|
||||
let count = self
|
||||
.session_storage
|
||||
.revoke_all_user_sessions(user.id())
|
||||
.await?;
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.sessions_revoked_on_delete",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
mode = ?mode,
|
||||
count = count,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user