diff --git a/docs/architecture/user-lifecycle.md b/docs/architecture/user-lifecycle.md index eac87b2a..e1c22876 100644 --- a/docs/architecture/user-lifecycle.md +++ b/docs/architecture/user-lifecycle.md @@ -54,12 +54,12 @@ pub enum DeletionMode { `UserLifecycleService` aggregates registered hooks and fans out events with **per-event failure semantics**. The trait itself is uniform; the dispatcher decides whether to await, whether to spawn, and whether `Err` aborts. -| Event | Awaited? | On `Err` | -|--------------------|---------------|-----------------------------------------| -| `on_user_created` | yes (sync) | log-and-continue (retry on next login) | -| `on_user_login` | yes (sync) | log-and-continue (idempotent retry) | -| `on_user_logout` | no (spawned) | logged, never propagated | -| `on_user_deleted` | yes (sync) | log-and-continue today; PR 4 makes it abort-the-transaction | +| Event | Awaited? | On `Err` | +|--------------------|------------------|-----------------------------------------| +| `on_user_created` | yes (sync) | log-and-continue (retry on next login) | +| `on_user_login` | yes (sync) | log-and-continue (idempotent retry) | +| `on_user_logout` | no (spawned) | logged, never propagated | +| `on_user_deleted` | yes (sync, in tx) | **abort the transaction** — first `Err` rolls back the user DELETE and propagates to the admin endpoint as a 500 | The asymmetry is deliberate. `on_user_created` and `on_user_login` must complete before the session token is returned, so callers see consistent state. `on_user_logout` is bookkeeping; the HTTP response shouldn't wait for cache flushes — the dispatcher spawns. `on_user_deleted` will become atomic-with-the-DELETE in PR 4 when a transaction handle joins the trait signature. @@ -111,7 +111,7 @@ These are codified in the module-level docstring of `application/ports/user_life 4. **Per-session logout firing.** When a flow revokes multiple sessions in one call (e.g. `revoke_all_user_sessions` on password change), today the dispatcher fires `on_user_logout` ONCE per logical revoke-call. PR 4's `SessionRevocationLifecycleHook` will refine to once-per-session for proper audit granularity. Hooks must accept N redundant calls with the same reason — keep them idempotent. -5. **`on_user_deleted` is post-commit today.** The user row is already gone when the hook fires. Returning `Err` cannot roll back. PR 4 refactors `delete_user_admin` to expose a transaction handle, at which point the trait gains `tx: &mut Transaction` and `Err` will abort the delete. +5. **`on_user_deleted` runs inside the delete transaction.** The user row still exists when the hook fires; the dispatcher commits only after every hook returns `Ok(())`. Returning `Err` aborts the whole transaction — including the user DELETE itself. Implementors get `tx: &mut sqlx::Transaction<'_, Postgres>` so cleanup queries land in the same tx (e.g. session revocation with audit trail before FK CASCADE wipes the rows). Be conservative about returning `Err`: an abort means the admin's delete operation fails, leaving the user intact. 6. **Hook order is registration order.** The DI factory determines firing sequence. If two hooks have an ordering dependency (e.g. home-folder must exist before default-calendar can be seeded inside it), the dependent hook registers AFTER the producer. Document the convention inline in the DI block. @@ -120,14 +120,47 @@ These are codified in the module-level docstring of `application/ports/user_life | Hook | Lives in | Responsibility | |---|---|---| | `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher) | All four events: emits one `tracing::info!(target: "audit", event = "user.*", ...)` per call, with `is_external` as a field. Co-located because audit is cross-cutting with no domain owner. | -| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision "My Folder - {username}" via `FolderService::ensure_home_folder`. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: `Ok(())` for PR 3 — PR 4 adds the trash-vs-hard-delete policy based on `DeletionMode`. Owns the responsibility that pre-PR 3 was scattered across four eager `create_personal_folder` calls in `AuthApplicationService` and one self-heal at the folder-listing path. | +| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision "My Folder - {username}" via `FolderService::ensure_home_folder`. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: per-mode `tracing::info!` event so audit distinguishes AdminDelete from GdprPurge — the FK CASCADE on `storage.folders.user_id` handles the actual row removal. Trash-with-retention is documented as future work. | +| `AuthzCacheLifecycleHook` | `src/infrastructure/services/pg_acl_engine.rs` (same module as the Moka cache it invalidates) | `on_user_logout` + `on_user_deleted`: `engine.invalidate_user_groups_cache(user.id())` — drops the cached transitive-group expansion immediately so a re-login (or a re-created account with the same id) sees fresh memberships without waiting for the 30 s TTL. `on_user_created` + `on_user_login`: `Ok(())` (no stale entry could exist for these). | +| `SessionRevocationLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher; no dedicated session service module today) | `on_user_deleted`: explicit `session_storage.revoke_all_user_sessions(user.id())` + aggregate audit event (`event = "user.sessions_revoked_on_delete", count = N`). Replaces the silent FK CASCADE with an observable revocation. All other events: `Ok(())`. | +| `DeletionMode` | enum on the trait | Distinguishes admin-initiated delete (`AdminDelete` — currently identical to GDPR but reserved for a future trash-with-retention policy) from GDPR right-to-erasure purge (`GdprPurge` — for a future sweeper). PR 4 ships the variants; future PRs may add per-mode behaviour. | Subsequent PRs add: -- `AuthzCacheLifecycleHook` (PR 4) — invalidates the `user_groups_cache` Moka entry on logout/delete. -- `SessionRevocationLifecycleHook` (PR 4) — refines per-session logout granularity; explicit session revocation inside the user-delete transaction. - `ExternalIdentityLifecycleHook` (PR 5, stub for now) — populated by the upcoming magic-link external-user feature. +### How the delete transaction composes + +```text +AuthApplicationService::delete_user_admin(user_id) + │ + ▼ +BEGIN + │ + ▼ +dispatch_deleted(user, AdminDelete, &mut tx) + │ + ├── AuditLifecycleHook → tracing::info!(event="user.deleted", mode=...) + ├── HomeFolderLifecycleHook → tracing::info!("home folder will be removed via FK CASCADE") + ├── AuthzCacheLifecycleHook → engine.invalidate_user_groups_cache(user_id) + └── SessionRevocationLifecycleHook + → session_storage.revoke_all_user_sessions(user_id) + → tracing::info!(event="user.sessions_revoked_on_delete", + count=N) + │ + ▼ +DELETE FROM auth.users WHERE id = $1 + │ + ▼ (FK CASCADE removes folders, files, app_passwords, device_codes, calendar + │ shares, address-book shares, etc.; trg_cleanup_grants_user removes the + │ user's grants) + │ + ▼ +COMMIT +``` + +If any hook returns `Err`, the dispatcher propagates it; `delete_user_admin` rolls back the transaction and surfaces the error to the admin endpoint as a 500. The user row, all sessions, all folders/files, all grants — everything stays in place. Hook implementors should keep that strong constraint in mind: returning `Err` from `on_user_deleted` is a heavy hammer. + ### Worked example: brand-new user logs in for the first time 1. Client POSTs `/api/auth/login` with valid credentials. diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs index c37b8fe8..d5cf5b89 100644 --- a/src/application/ports/user_lifecycle.rs +++ b/src/application/ports/user_lifecycle.rs @@ -32,7 +32,7 @@ //! | `on_user_created` | yes | log-and-continue (retry on next login) | //! | `on_user_login` | yes | log-and-continue (idempotent retry) | //! | `on_user_logout` | no | fire-and-forget (spawned), error logged| -//! | `on_user_deleted` | yes | log-and-continue today; PR 4 will switch to abort-the-transaction once the dispatcher fires inside the delete tx | +//! | `on_user_deleted` | yes (in tx) | abort the transaction (Err propagates) | //! //! # Tips for hook implementors //! @@ -79,12 +79,15 @@ //! manually. The `on_user_login` safety-net will retry on the next //! successful authentication. //! -//! 7. **`on_user_deleted` is post-commit today.** The user row is -//! already gone when the hook fires. Returning `Err` cannot roll -//! back the delete. PR 4 will refactor `delete_user_admin` to expose -//! a transaction handle, at which point the trait gains a `tx: -//! &mut Transaction` parameter and `Err` will abort the delete. For -//! now: best-effort cleanup, log failures, don't assume atomicity. +//! 7. **`on_user_deleted` runs inside the delete transaction.** The +//! user row still exists when the hook fires; the dispatcher commits +//! only after every hook returns `Ok(())`. Returning `Err` aborts +//! the whole transaction — including the user DELETE itself. +//! Implementors get `tx: &mut sqlx::Transaction<'_, Postgres>` so +//! cleanup queries land in the same tx (e.g. session revocation +//! with audit trail before FK CASCADE wipes the rows). Be +//! conservative about returning `Err`: an abort means the admin's +//! delete operation fails, leaving the user intact. //! //! 8. **Hook order is registration order.** The DI factory at //! [`AppServiceFactory`] determines the firing sequence. If two hooks @@ -178,9 +181,16 @@ pub trait UserLifecycleHook: Send + Sync { /// The HTTP response shouldn't wait for downstream cache flushes. async fn on_user_logout(&self, user: &User, reason: LogoutReason) -> Result<(), DomainError>; - /// Fires after a successful `DELETE FROM auth.users`. **Post-commit - /// today** — see tip #7. The user row is already gone; cleanup must - /// be best-effort. PR 4 will refactor to provide a transaction - /// handle and switch to before-commit-in-tx semantics. - async fn on_user_deleted(&self, user: &User, mode: DeletionMode) -> Result<(), DomainError>; + /// Fires inside the `delete_user_admin` transaction, BEFORE the + /// `DELETE FROM auth.users` row removal. The user row still exists + /// at this point; `user.id()` is safe to reference in queries on + /// the same `tx`. Returning `Err` rolls back the transaction — + /// the user is NOT deleted and the admin's request fails. See + /// tip #7 in the module docstring. + async fn on_user_deleted( + &self, + user: &User, + mode: DeletionMode, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DomainError>; } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 2588b8c3..f008a499 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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(()) } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 5d2e73f8..e6150f96 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -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(()) } } diff --git a/src/application/services/user_lifecycle_service.rs b/src/application/services/user_lifecycle_service.rs index 16047c76..d12249ff 100644 --- a/src/application/services/user_lifecycle_service.rs +++ b/src/application/services/user_lifecycle_service.rs @@ -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, +} + +impl SessionRevocationLifecycleHook { + pub fn new(session_storage: Arc) -> 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(()) + } +} diff --git a/src/common/di.rs b/src/common/di.rs index b7e609b2..8426102b 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -693,13 +693,26 @@ impl AppServiceFactory { // User-lifecycle dispatcher. Hook order is registration order; // document dependencies inline if/when any arise. Today: - // 1. AuditLifecycleHook — fires first so the audit - // event is recorded even if a - // later hook errors out. - // 2. HomeFolderLifecycleHook — provisions the user's home - // folder on created/login (no-op - // for external users). - // Subsequent PRs register AuthzCacheLifecycleHook (PR 4) etc. + // 1. AuditLifecycleHook — fires first so the + // audit event is recorded + // even if a later hook + // errors out. + // 2. HomeFolderLifecycleHook — provisions the user's + // home folder on + // created/login (no-op + // for external users). + // 3. AuthzCacheLifecycleHook — invalidates the + // Moka group-expansion + // cache on logout/delete + // so a re-login sees fresh + // membership immediately. + // 4. SessionRevocationLifecycleHook — explicit per-user + // session revocation on + // delete (with audit) — + // replaces the silent FK + // CASCADE. + // PR 5 will append ExternalIdentityLifecycleHook (stub). + let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone())); let user_lifecycle = Arc::new( crate::application::services::user_lifecycle_service::UserLifecycleService::new() .with_hook(Arc::new( @@ -709,6 +722,16 @@ impl AppServiceFactory { crate::application::services::folder_service::HomeFolderLifecycleHook::new( apps.folder_service_concrete.clone(), ), + )) + .with_hook(Arc::new( + crate::infrastructure::services::pg_acl_engine::AuthzCacheLifecycleHook::new( + authorization.clone(), + ), + )) + .with_hook(Arc::new( + crate::application::services::user_lifecycle_service::SessionRevocationLifecycleHook::new( + session_repo_for_hook, + ), )), ); diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 8d2669c8..d1c31e18 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -27,6 +27,14 @@ impl UserPgRepository { Self { pool } } + /// Borrowed access to the connection pool. Exposed so callers can + /// open transactions that span this repo and other repos / hooks + /// (e.g. `AuthApplicationService::delete_user_admin` opening a tx + /// that wraps the lifecycle dispatcher + the DELETE). + pub fn pool(&self) -> &PgPool { + &self.pool + } + // Helper method to map SQL errors to domain errors pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError { match err { diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 169c6432..5386514c 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -114,6 +114,17 @@ impl PgAclEngine { } } + /// Drop the cached transitive-group expansion for one user, forcing + /// the next `expand_user(uid)` to walk the recursive CTE again. + /// + /// Called by [`AuthzCacheLifecycleHook`] on `on_user_logout` / + /// `on_user_deleted` so a re-login (or a re-created account with the + /// same id) doesn't observe stale memberships during the 30 s TTL + /// window. Cheap — moka's `invalidate` is a single concurrent-map op. + pub async fn invalidate_user_groups_cache(&self, user_id: Uuid) { + self.user_groups_cache.invalidate(&user_id).await; + } + /// Expand a user subject into the set of subject UUIDs that should match /// in `access_grants`: the user's own UUID, every group the user is /// transitively a member of, and the implicit `INTERNAL_GROUP_ID`. @@ -1590,3 +1601,72 @@ impl AuthorizationEngine for PgAclEngine { Ok(result.rows_affected() as usize) } } + +// ───────────────────────────────────────────────────────────────────────────── +// AuthzCacheLifecycleHook +// +// Owns invalidation of the `user_groups_cache` Moka entry when a user's +// state changes in ways that affect transitive-group expansion (logout +// — so a re-login with new group memberships doesn't observe a stale +// expansion during the 30 s TTL window; delete — so a re-created +// account with the same id doesn't inherit the old cached value). +// +// Lives in this file (not under a centralised `lifecycle/` directory) +// because the authz engine owns its own cache invariants. See the +// "owner-located convention" note in +// `docs/architecture/user-lifecycle.md`. +// ───────────────────────────────────────────────────────────────────────────── + +use async_trait::async_trait; + +use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; +use crate::domain::entities::user::User; + +/// Lifecycle hook: drops the `user_groups_cache` entry for one user on +/// logout / deletion so the next authz check rebuilds it from current +/// `subject_group_members` rows. +pub struct AuthzCacheLifecycleHook { + engine: Arc, +} + +impl AuthzCacheLifecycleHook { + pub fn new(engine: Arc) -> Self { + Self { engine } + } +} + +#[async_trait] +impl UserLifecycleHook for AuthzCacheLifecycleHook { + fn name(&self) -> &'static str { + "authz_cache" + } + + async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> { + // New user can't have a stale cache entry (no prior `expand_user` + // call has produced one). Explicit no-op per the trait convention. + Ok(()) + } + + async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> { + // Login doesn't change group membership; the cache (if present) + // is still correct. + Ok(()) + } + + async fn on_user_logout(&self, user: &User, _reason: LogoutReason) -> Result<(), DomainError> { + self.engine.invalidate_user_groups_cache(user.id()).await; + Ok(()) + } + + async fn on_user_deleted( + &self, + user: &User, + _mode: DeletionMode, + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DomainError> { + // No DB writes here — just memory invalidation. `_tx` is + // intentionally ignored. + self.engine.invalidate_user_groups_cache(user.id()).await; + Ok(()) + } +}