refactor(userLifecycle): add user lifecycle, more clarety + better integration for the future

This commit is contained in:
Edouard Vanbelle
2026-06-01 15:35:24 +02:00
parent 44781643ef
commit bb6429a620
11 changed files with 631 additions and 3 deletions
+1
View File
@@ -21,4 +21,5 @@ pub mod storage_ports;
pub mod thumbnail_ports;
pub mod transcode_ports;
pub mod trash_ports;
pub mod user_lifecycle;
pub mod zip_ports;
+186
View File
@@ -0,0 +1,186 @@
//! User-lifecycle hook port.
//!
//! Observer notified by [`AuthApplicationService`] when a user transitions
//! through one of four lifecycle events: created, login, logout, deleted.
//! Register concrete impls with [`UserLifecycleService`] during DI wiring;
//! the dispatcher fans out each event to every registered hook.
//!
//! Each impl owns ONE concern. Folder service owns home-folder provisioning,
//! authz engine owns its cache invalidation, audit service owns the audit
//! trail, etc. New services plug in by registering a hook; the dispatcher
//! itself never gains domain knowledge.
//!
//! # Convention: explicit no-ops
//!
//! Every implementor **must** provide all four methods — use an explicit
//! one-liner `Ok(())` for events the implementor does not care about. This
//! forces conscious acknowledgement of every lifecycle event rather than
//! silent omission via trait defaults. Mirrors the [`FileLifecycleHook`]
//! convention at `application/ports/file_lifecycle.rs`.
//!
//! # Convention: async + per-event semantics
//!
//! Unlike [`FileLifecycleHook`] (sync fire-and-forget), user-lifecycle
//! events are async because some require synchronous semantics:
//! provisioning must finish before the session token is returned;
//! deletion cleanup must commit atomically with the user DELETE.
//!
//! Per-event failure model (encoded in the dispatcher, not the trait):
//!
//! | Event | Awaited? | On `Err`? |
//! |--------------------|----------|----------------------------------------|
//! | `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 |
//!
//! # Tips for hook implementors
//!
//! 1. **First-ever login detection.** `on_user_login` fires after
//! credentials validate but **before** `user.register_login()` is
//! called for this session. So `user.last_login_at().is_none()` is a
//! reliable "this is the first login since account creation" signal —
//! useful for welcome emails, one-shot default-resource seeding,
//! "complete your profile" prompts.
//!
//! 2. **External-user short-circuit.** Every hook that provisions or
//! manages user-owned resources (folders, calendars, address books)
//! should start with `if user.is_external() { return Ok(()); }`.
//! External users are grant-only — they don't own storage. The
//! `is_external` flag lands in PR 2 of this work; until then, treat
//! every user as internal.
//!
//! 3. **Idempotency is mandatory.** `on_user_login` fires on every
//! successful authentication. A hook that creates a resource must
//! first check whether the resource already exists. Same for cache
//! invalidation, audit deduplication, etc. The `on_user_login`
//! safety-net only works if hooks no-op when their work is already
//! done.
//!
//! 4. **External → internal conversion needs no special event.** When
//! admin converts an external user to internal (`UPDATE auth.users
//! SET is_external = FALSE`), the user's next login fires
//! `on_user_login` with the new flag value. Idempotent hooks see
//! `!is_external` + missing resources → provision. No
//! `on_user_converted` method needed.
//!
//! 5. **Per-session logout firing.** When a flow revokes multiple
//! sessions (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
//! this to once-per-session for proper audit granularity. Hooks must
//! therefore accept N redundant calls with the same reason — keep
//! them idempotent and side-effect-free.
//!
//! 6. **Failure swallowing on create/login.** If your hook returns
//! `Err`, the user is still created / logged in; only your hook's
//! effect is delayed. Log enough detail via `tracing::error!` that a
//! subsequent investigation can identify the user and retry
//! 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.
//!
//! 8. **Hook order is registration order.** The DI factory at
//! [`AppServiceFactory`] determines the 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 in the DI
//! block where order matters.
use async_trait::async_trait;
use crate::common::errors::DomainError;
use crate::domain::entities::user::User;
/// Reason a user session is ending. Hooks that don't care about the cause
/// (e.g. cache invalidation) ignore the value; audit-style hooks branch on
/// it to emit distinguishable events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogoutReason {
/// User clicked logout. Single-session.
UserInitiated,
/// Session TTL hit. Single-session.
SessionExpired,
/// Admin invoked single-session revocation (e.g. "log out other
/// devices"). Today this fires from `logout_all` and from individual
/// admin endpoints if/when they exist.
AdminRevoked,
/// `user.active` flipped to `FALSE` → all sessions revoked. Fires once
/// per logical revoke-call today (see tip #5).
AccountDisabled,
/// Password was changed → sibling sessions invalidated to force re-login
/// with the new password.
PasswordChanged,
/// Refresh-token reuse detected by the session-family guard. Entire
/// family revoked because the rotation was probably stolen.
TokenReused,
}
/// How aggressively `on_user_deleted` cleanup should run. Today both
/// variants are equivalent (only `AuditLifecycleHook` exists, and it logs
/// regardless). The split exists so PR 4's `HomeFolderLifecycleHook` can
/// trash on `AdminDelete` but hard-delete on `GdprPurge`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeletionMode {
/// Admin deletes a user through the UI. Resources move to trash for
/// the retention window; recoverable.
AdminDelete,
/// GDPR right-to-erasure sweeper. Hard-delete everything; not
/// recoverable. (No sweeper is wired today; the variant is reserved.)
GdprPurge,
}
/// Observer for user-lifecycle events. See module-level docstring for the
/// convention, semantics, and 8 tips for implementors.
///
/// `#[async_trait]` is required to make the trait `dyn`-compatible —
/// the dispatcher holds `Arc<dyn UserLifecycleHook>`. Without it,
/// native `async fn in trait` returns an opaque type that has no vtable
/// representation. The same crate (`async-trait` 0.1.x) is used by other
/// async ecosystem deps and was already transitively in Cargo.lock.
#[async_trait]
pub trait UserLifecycleHook: Send + Sync {
/// Short identifier used in tracing / error logs. Example: `"home_folder"`,
/// `"audit"`, `"authz_cache"`.
fn name(&self) -> &'static str;
/// Fires once after INSERT into `auth.users` succeeds, regardless of
/// the creation path (self-register, admin-create, OIDC JIT, future
/// magic-link bootstrap).
///
/// The dispatcher logs `Err` and continues — the user is still
/// created and the next `on_user_login` will run an idempotent retry.
async fn on_user_created(&self, user: &User) -> Result<(), DomainError>;
/// Fires after every successful authentication, BEFORE the user's
/// `last_login_at` is updated for this session and BEFORE the session
/// token is returned to the caller.
///
/// **Idempotency is mandatory** — this fires on every login, not just
/// the first. Hooks that provision must check whether their resource
/// already exists before creating it. See tip #3 in the module
/// docstring.
///
/// `user.last_login_at().is_none()` distinguishes the first-ever
/// login from subsequent ones. See tip #1.
async fn on_user_login(&self, user: &User) -> Result<(), DomainError>;
/// Fires on session termination. `reason` lets hooks distinguish
/// causes — audit cares; cache invalidation usually doesn't.
///
/// Spawned by the dispatcher — `Err` is logged but never propagates.
/// 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>;
}
@@ -6,7 +6,9 @@ use crate::application::ports::auth_ports::{
UserStoragePort,
};
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
use crate::application::services::folder_service::FolderService;
use crate::application::services::user_lifecycle_service::UserLifecycleService;
use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::session::Session;
@@ -72,6 +74,9 @@ pub struct AuthApplicationService {
password_hasher: Arc<Argon2PasswordHasher>,
token_service: Arc<JwtTokenService>,
folder_service: Option<Arc<FolderService>>,
/// Dispatcher for user-lifecycle events. `None` only in tests that don't
/// exercise the lifecycle path; production DI always wires this.
user_lifecycle: Option<Arc<UserLifecycleService>>,
/// Path to the storage directory, used for disk-space–aware quota calculation
storage_path: PathBuf,
oidc: RwLock<OidcState>,
@@ -97,6 +102,7 @@ impl AuthApplicationService {
password_hasher,
token_service,
folder_service: None,
user_lifecycle: None,
storage_path,
oidc: RwLock::new(OidcState {
service: None,
@@ -165,6 +171,14 @@ impl AuthApplicationService {
self
}
/// Configures the user-lifecycle dispatcher. Wired by the DI factory
/// after core services are up. PR 1: only AuditLifecycleHook is
/// registered, so calls without this configured silently no-op.
pub fn with_user_lifecycle(mut self, lifecycle: Arc<UserLifecycleService>) -> Self {
self.user_lifecycle = Some(lifecycle);
self
}
/// Configures the OIDC service
pub fn with_oidc(self, oidc_service: Arc<OidcService>, oidc_config: OidcConfig) -> Self {
{
@@ -279,6 +293,13 @@ impl AuthApplicationService {
// Save user
let created_user = self.user_storage.create_user(user).await?;
// Lifecycle: notify hooks (audit, future provisioning, etc.).
// PR 3 will move the personal-folder creation below into a
// HomeFolderLifecycleHook fired here; for now both run.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
}
// Create personal folder for the user
self.create_personal_folder(&dto.username, created_user.id())
.await;
@@ -356,6 +377,12 @@ impl AuthApplicationService {
let created_user = self.user_storage.create_user(user).await?;
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
// HomeFolderLifecycleHook fired here.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
}
// Create personal folder for the admin
self.create_personal_folder(&username, created_user.id())
.await;
@@ -401,6 +428,13 @@ impl AuthApplicationService {
));
}
// Lifecycle: dispatch login BEFORE register_login() so hooks
// observing `last_login_at().is_none()` see "first ever login"
// correctly. See tip #1 in user_lifecycle.rs.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_login(&user).await;
}
// Update last login
user.register_login();
self.user_storage.update_user(user.clone()).await?;
@@ -496,6 +530,13 @@ impl AuthApplicationService {
self.session_storage
.revoke_session_family(session.family_id())
.await?;
// Lifecycle: TokenReused logout — fired once per logical
// revoke-family call. PR 4 may refine to per-session firing.
if let Some(lc) = &self.user_lifecycle
&& let Ok(user) = self.user_storage.get_user_by_id(session.user_id()).await
{
lc.dispatch_logout(user, LogoutReason::TokenReused);
}
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
@@ -576,6 +617,15 @@ impl AuthApplicationService {
// Revoke session
self.session_storage.revoke_session(session.id()).await?;
// Lifecycle: notify hooks. One extra DB roundtrip per logout
// (user load) is acceptable — logout is rare. Failure to load
// the user is non-fatal: we already revoked the session.
if let Some(lc) = &self.user_lifecycle
&& let Ok(user) = self.user_storage.get_user_by_id(user_id).await
{
lc.dispatch_logout(user, LogoutReason::UserInitiated);
}
Ok(())
}
@@ -637,13 +687,19 @@ impl AuthApplicationService {
user.update_password_hash(new_hash);
// Save updated user
self.user_storage.update_user(user).await?;
self.user_storage.update_user(user.clone()).await?;
// Optional: revoke all sessions to force re-login with new password
self.session_storage
.revoke_all_user_sessions(user_id)
.await?;
// Lifecycle: PasswordChanged logout — fired once per logical
// revoke-all call. PR 4 may refine to per-session firing.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_logout(user, LogoutReason::PasswordChanged);
}
Ok(())
}
@@ -828,6 +884,12 @@ impl AuthApplicationService {
.await?;
}
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
// HomeFolderLifecycleHook fired here.
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created).await;
}
// Create personal folder
self.create_personal_folder(&dto.username, created.id())
.await;
@@ -883,7 +945,16 @@ impl AuthApplicationService {
// 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
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).
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_deleted(&user, DeletionMode::AdminDelete).await;
}
Ok(())
}
/// Activate or deactivate a user (admin only)
@@ -1175,7 +1246,12 @@ impl AuthApplicationService {
.await
{
Ok(mut existing_user) => {
// User exists — update last login and sync avatar from IdP
// User exists — dispatch login BEFORE register_login() so
// hooks observe `last_login_at = None` on the very first
// login (see tip #1 in the trait docstring).
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_login(&existing_user).await;
}
existing_user.register_login();
existing_user.set_image(claims.picture.clone());
self.user_storage.update_user(existing_user.clone()).await?;
@@ -1273,6 +1349,14 @@ impl AuthApplicationService {
let created_user = self.user_storage.create_user(new_user).await?;
// Lifecycle: created (audit) + login (no register_login()
// for a fresh OIDC user means `last_login_at` is naturally
// None → first-login detection works).
if let Some(lc) = &self.user_lifecycle {
lc.dispatch_created(&created_user).await;
lc.dispatch_login(&created_user).await;
}
// Create personal folder
self.create_personal_folder(&username, created_user.id())
.await;
+1
View File
@@ -25,6 +25,7 @@ pub mod storage_settings_service;
pub mod storage_usage_service;
pub mod subject_group_service;
pub mod trash_service;
pub mod user_lifecycle_service;
pub mod wopi_lock_service;
pub mod wopi_token_service;
@@ -0,0 +1,184 @@
//! User-lifecycle dispatcher + the always-on `AuditLifecycleHook`.
//!
//! [`UserLifecycleService`] aggregates every registered
//! [`UserLifecycleHook`] and fans out each lifecycle event with
//! per-event failure semantics. See `user_lifecycle.rs` for the trait
//! contract and tips for implementors.
//!
//! [`AuditLifecycleHook`] lives in this file (not under
//! `infrastructure/services/`) because it's cross-cutting — no domain
//! service owns "user-lifecycle audit", and the hook is small enough that
//! a separate module would be ceremony. Every other hook lives with the
//! service that owns its work (see `architecture/user-lifecycle.md`).
use std::sync::Arc;
use async_trait::async_trait;
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::common::errors::DomainError;
use crate::domain::entities::user::User;
/// Composite dispatcher for user-lifecycle events.
///
/// Mirrors the [`FileLifecycleService`] shape: a `Vec<Arc<dyn ...>>` and a
/// builder. The per-event failure semantics differ from the file-side
/// (file events are sync fire-and-forget; user events have per-method
/// rules — see the trait docstring).
pub struct UserLifecycleService {
hooks: Vec<Arc<dyn UserLifecycleHook>>,
}
impl Default for UserLifecycleService {
fn default() -> Self {
Self::new()
}
}
impl UserLifecycleService {
pub fn new() -> Self {
Self { hooks: Vec::new() }
}
pub fn with_hook(mut self, hook: Arc<dyn UserLifecycleHook>) -> Self {
self.hooks.push(hook);
self
}
/// Created: log-and-continue. If a hook returns `Err`, the user is
/// still created — the next login's `on_user_login` will retry
/// idempotently. See tip #6 in the trait docstring.
pub async fn dispatch_created(&self, user: &User) {
for h in &self.hooks {
if let Err(e) = h.on_user_created(user).await {
tracing::error!(
target: "user_lifecycle",
hook = h.name(),
user_id = %user.id(),
error = %e,
"on_user_created failed; will retry on next login"
);
}
}
}
/// Login: log-and-continue. Same reasoning as `dispatch_created`.
/// Must fire BEFORE `user.register_login()` so that hooks observing
/// `last_login_at().is_none()` correctly detect the first-ever login.
pub async fn dispatch_login(&self, user: &User) {
for h in &self.hooks {
if let Err(e) = h.on_user_login(user).await {
tracing::error!(
target: "user_lifecycle",
hook = h.name(),
user_id = %user.id(),
error = %e,
"on_user_login failed; will retry on next login"
);
}
}
}
/// Logout: fire-and-forget. Spawned so the HTTP response doesn't wait
/// for downstream cache flushes. Takes ownership of `User` because the
/// spawn outlives the caller's borrow.
pub fn dispatch_logout(&self, user: User, reason: LogoutReason) {
let hooks = self.hooks.clone();
tokio::spawn(async move {
for h in &hooks {
if let Err(e) = h.on_user_logout(&user, reason).await {
tracing::error!(
target: "user_lifecycle",
hook = h.name(),
reason = ?reason,
user_id = %user.id(),
error = %e,
"on_user_logout failed"
);
}
}
});
}
/// 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) {
for h in &self.hooks {
if let Err(e) = h.on_user_deleted(user, mode).await {
tracing::error!(
target: "user_lifecycle",
hook = h.name(),
mode = ?mode,
user_id = %user.id(),
error = %e,
"on_user_deleted failed"
);
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AuditLifecycleHook
//
// Always-on observer. Emits one structured `tracing::info!(target: "audit",
// ...)` line per event. The only hook registered in PR 1; subsequent PRs
// add HomeFolderLifecycleHook, AuthzCacheLifecycleHook, etc., each living
// next to the service it works for.
// ─────────────────────────────────────────────────────────────────────────────
/// Cross-cutting audit observer for user-lifecycle events. Co-located with
/// the dispatcher because audit has no domain owner.
pub struct AuditLifecycleHook;
#[async_trait]
impl UserLifecycleHook for AuditLifecycleHook {
fn name(&self) -> &'static str {
"audit"
}
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
tracing::info!(
target: "audit",
event = "user.created",
user_id = %user.id(),
username = %user.username(),
);
Ok(())
}
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
tracing::info!(
target: "audit",
event = "user.login",
user_id = %user.id(),
username = %user.username(),
first_login = user.last_login_at().is_none(),
);
Ok(())
}
async fn on_user_logout(&self, user: &User, reason: LogoutReason) -> Result<(), DomainError> {
tracing::info!(
target: "audit",
event = "user.logout",
user_id = %user.id(),
username = %user.username(),
reason = ?reason,
);
Ok(())
}
async fn on_user_deleted(&self, user: &User, mode: DeletionMode) -> Result<(), DomainError> {
tracing::info!(
target: "audit",
event = "user.deleted",
user_id = %user.id(),
username = %user.username(),
mode = ?mode,
);
Ok(())
}
}