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:
@@ -55,11 +55,11 @@ 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.
|
`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` |
|
| Event | Awaited? | On `Err` |
|
||||||
|--------------------|---------------|-----------------------------------------|
|
|--------------------|------------------|-----------------------------------------|
|
||||||
| `on_user_created` | yes (sync) | log-and-continue (retry on next login) |
|
| `on_user_created` | yes (sync) | log-and-continue (retry on next login) |
|
||||||
| `on_user_login` | yes (sync) | log-and-continue (idempotent retry) |
|
| `on_user_login` | yes (sync) | log-and-continue (idempotent retry) |
|
||||||
| `on_user_logout` | no (spawned) | logged, never propagated |
|
| `on_user_logout` | no (spawned) | logged, never propagated |
|
||||||
| `on_user_deleted` | yes (sync) | log-and-continue today; PR 4 makes it abort-the-transaction |
|
| `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.
|
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.
|
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.
|
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 |
|
| 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. |
|
| `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:
|
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.
|
- `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
|
### Worked example: brand-new user logs in for the first time
|
||||||
|
|
||||||
1. Client POSTs `/api/auth/login` with valid credentials.
|
1. Client POSTs `/api/auth/login` with valid credentials.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
//! | `on_user_created` | yes | log-and-continue (retry on next login) |
|
//! | `on_user_created` | yes | log-and-continue (retry on next login) |
|
||||||
//! | `on_user_login` | yes | log-and-continue (idempotent retry) |
|
//! | `on_user_login` | yes | log-and-continue (idempotent retry) |
|
||||||
//! | `on_user_logout` | no | fire-and-forget (spawned), error logged|
|
//! | `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
|
//! # Tips for hook implementors
|
||||||
//!
|
//!
|
||||||
@@ -79,12 +79,15 @@
|
|||||||
//! manually. The `on_user_login` safety-net will retry on the next
|
//! manually. The `on_user_login` safety-net will retry on the next
|
||||||
//! successful authentication.
|
//! successful authentication.
|
||||||
//!
|
//!
|
||||||
//! 7. **`on_user_deleted` is post-commit today.** The user row is
|
//! 7. **`on_user_deleted` runs inside the delete transaction.** The
|
||||||
//! already gone when the hook fires. Returning `Err` cannot roll
|
//! user row still exists when the hook fires; the dispatcher commits
|
||||||
//! back the delete. PR 4 will refactor `delete_user_admin` to expose
|
//! only after every hook returns `Ok(())`. Returning `Err` aborts
|
||||||
//! a transaction handle, at which point the trait gains a `tx:
|
//! the whole transaction — including the user DELETE itself.
|
||||||
//! &mut Transaction` parameter and `Err` will abort the delete. For
|
//! Implementors get `tx: &mut sqlx::Transaction<'_, Postgres>` so
|
||||||
//! now: best-effort cleanup, log failures, don't assume atomicity.
|
//! 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
|
//! 8. **Hook order is registration order.** The DI factory at
|
||||||
//! [`AppServiceFactory`] determines the firing sequence. If two hooks
|
//! [`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.
|
/// The HTTP response shouldn't wait for downstream cache flushes.
|
||||||
async fn on_user_logout(&self, user: &User, reason: LogoutReason) -> Result<(), DomainError>;
|
async fn on_user_logout(&self, user: &User, reason: LogoutReason) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Fires after a successful `DELETE FROM auth.users`. **Post-commit
|
/// Fires inside the `delete_user_admin` transaction, BEFORE the
|
||||||
/// today** — see tip #7. The user row is already gone; cleanup must
|
/// `DELETE FROM auth.users` row removal. The user row still exists
|
||||||
/// be best-effort. PR 4 will refactor to provide a transaction
|
/// at this point; `user.id()` is safe to reference in queries on
|
||||||
/// handle and switch to before-commit-in-tx semantics.
|
/// the same `tx`. Returning `Err` rolls back the transaction —
|
||||||
async fn on_user_deleted(&self, user: &User, mode: DeletionMode) -> Result<(), DomainError>;
|
/// 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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -968,20 +968,46 @@ impl AuthApplicationService {
|
|||||||
Ok(UserDto::from(user))
|
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> {
|
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?;
|
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||||
tracing::info!("Admin deleting user: {} ({})", user.username(), user_id);
|
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
|
let mut tx = self
|
||||||
// this inside a transaction so hook failures can abort the
|
.user_storage
|
||||||
// delete — see tip #7 in user_lifecycle.rs).
|
.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 {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -778,12 +778,31 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_deleted(&self, _user: &User, _mode: DeletionMode) -> Result<(), DomainError> {
|
async fn on_user_deleted(
|
||||||
// PR 4 will fill this in with the trash-vs-hard-delete policy
|
&self,
|
||||||
// (AdminDelete → trash with retention, GdprPurge → hard delete).
|
user: &User,
|
||||||
// For PR 3 the eager `DELETE FROM auth.users` cascades via the
|
mode: DeletionMode,
|
||||||
// existing FK on storage.folders to remove the home folder, so a
|
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
// no-op here matches behaviour parity.
|
) -> 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,23 +100,30 @@ impl UserLifecycleService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deleted: log-and-continue (post-commit today). PR 4 refactors
|
/// Deleted: runs inside the `delete_user_admin` transaction. First
|
||||||
/// `delete_user_admin` to expose a transaction handle and switches
|
/// `Err` propagates and aborts the transaction — the user is NOT
|
||||||
/// this to abort-on-first-Err to make cleanup atomic with the user
|
/// deleted. Hooks must keep their cleanup conservative. See tip #7
|
||||||
/// DELETE. See tip #7 in the trait docstring.
|
/// in the trait docstring.
|
||||||
pub async fn dispatch_deleted(&self, user: &User, mode: DeletionMode) {
|
pub async fn dispatch_deleted(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
mode: DeletionMode,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
for h in &self.hooks {
|
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!(
|
tracing::error!(
|
||||||
target: "user_lifecycle",
|
target: "user_lifecycle",
|
||||||
hook = h.name(),
|
hook = h.name(),
|
||||||
mode = ?mode,
|
mode = ?mode,
|
||||||
user_id = %user.id(),
|
user_id = %user.id(),
|
||||||
error = %e,
|
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(())
|
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!(
|
tracing::info!(
|
||||||
target: "audit",
|
target: "audit",
|
||||||
event = "user.deleted",
|
event = "user.deleted",
|
||||||
@@ -186,3 +200,82 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
|||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+29
-6
@@ -693,13 +693,26 @@ impl AppServiceFactory {
|
|||||||
|
|
||||||
// User-lifecycle dispatcher. Hook order is registration order;
|
// User-lifecycle dispatcher. Hook order is registration order;
|
||||||
// document dependencies inline if/when any arise. Today:
|
// document dependencies inline if/when any arise. Today:
|
||||||
// 1. AuditLifecycleHook — fires first so the audit
|
// 1. AuditLifecycleHook — fires first so the
|
||||||
// event is recorded even if a
|
// audit event is recorded
|
||||||
// later hook errors out.
|
// even if a later hook
|
||||||
// 2. HomeFolderLifecycleHook — provisions the user's home
|
// errors out.
|
||||||
// folder on created/login (no-op
|
// 2. HomeFolderLifecycleHook — provisions the user's
|
||||||
|
// home folder on
|
||||||
|
// created/login (no-op
|
||||||
// for external users).
|
// for external users).
|
||||||
// Subsequent PRs register AuthzCacheLifecycleHook (PR 4) etc.
|
// 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(
|
let user_lifecycle = Arc::new(
|
||||||
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
@@ -709,6 +722,16 @@ impl AppServiceFactory {
|
|||||||
crate::application::services::folder_service::HomeFolderLifecycleHook::new(
|
crate::application::services::folder_service::HomeFolderLifecycleHook::new(
|
||||||
apps.folder_service_concrete.clone(),
|
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,
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ impl UserPgRepository {
|
|||||||
Self { pool }
|
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
|
// Helper method to map SQL errors to domain errors
|
||||||
pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError {
|
pub fn map_sqlx_error(err: sqlx::Error) -> UserRepositoryError {
|
||||||
match err {
|
match err {
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// in `access_grants`: the user's own UUID, every group the user is
|
||||||
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
||||||
@@ -1590,3 +1601,72 @@ impl AuthorizationEngine for PgAclEngine {
|
|||||||
Ok(result.rows_affected() as usize)
|
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<PgAclEngine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthzCacheLifecycleHook {
|
||||||
|
pub fn new(engine: Arc<PgAclEngine>) -> 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user