From bb6429a62098e82db2e46847d7582b39446b39ec Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 1 Jun 2026 15:35:24 +0200 Subject: [PATCH] refactor(userLifecycle): add user lifecycle, more clarety + better integration for the future --- Cargo.lock | 1 + Cargo.toml | 1 + docs/.vitepress/config.mts | 2 + docs/architecture/user-lifecycle.md | 148 ++++++++++++++ src/application/ports/mod.rs | 1 + src/application/ports/user_lifecycle.rs | 186 ++++++++++++++++++ .../services/auth_application_service.rs | 90 ++++++++- src/application/services/mod.rs | 1 + .../services/user_lifecycle_service.rs | 184 +++++++++++++++++ src/common/di.rs | 14 ++ src/infrastructure/auth_factory.rs | 6 + 11 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/user-lifecycle.md create mode 100644 src/application/ports/user_lifecycle.rs create mode 100644 src/application/services/user_lifecycle_service.rs diff --git a/Cargo.lock b/Cargo.lock index d89b24f6..32982b46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3652,6 +3652,7 @@ dependencies = [ "argon2", "async-compression", "async-stream", + "async-trait", "async_zip", "aws-config", "aws-sdk-s3", diff --git a/Cargo.toml b/Cargo.toml index 67d1108b..1a693bf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" futures = "0.3.32" async-stream = "0.3.6" +async-trait = "0.1.83" mime_guess = "2.0.5" uuid = { version = "1.23.0", features = ["v4", "v7", "serde"] } thiserror = "2.0.18" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e0b8441d..8329bf6d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -110,10 +110,12 @@ export default defineConfig({ { text: "Resource Listing API", link: "/architecture/resource-listing" }, { text: "Storage Safety", link: "/architecture/file-system-safety" }, { text: "Database Transactions", link: "/architecture/database-transactions" }, + { text: "ReBAC Authorization", link: "/architecture/rebac-authorization" }, { text: "Share Integration", link: "/architecture/share-integration" }, { text: "Storage Quotas", link: "/architecture/storage-quotas" }, { text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" }, { text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" }, + { text: "User lifecycle", link: "/architecture/user-lifecycle" }, ], }, { text: "FAQ", link: "/faq" }, diff --git a/docs/architecture/user-lifecycle.md b/docs/architecture/user-lifecycle.md new file mode 100644 index 00000000..fda08ec4 --- /dev/null +++ b/docs/architecture/user-lifecycle.md @@ -0,0 +1,148 @@ +# User Lifecycle Hooks + +Observer pattern for per-service reactions to user state transitions: created, login, logout, deleted. Mirrors the [File and Blob lifecycle](/architecture/file-and-blob-lifecycle) pattern for files; deliberately diverges from it on two points (async + sync await for most events) because user-lifecycle work is rare and sometimes has hard synchronisation requirements. + +## Why hooks + +Before this work, four code paths in `AuthApplicationService` each called `create_personal_folder()` immediately after inserting an `auth.users` row (public registration, first-admin bootstrap, admin-creates-user, OIDC just-in-time provisioning), plus a fifth self-heal at `folder_service.rs` for users whose folder somehow went missing. **Five places, one concern, no shared abstraction.** Adding a future per-user resource — default calendar, address book, GPG keyring, external-identity provenance for the upcoming magic-link feature — would have meant touching all five. + +Hooks fix this once. Each domain service implements `UserLifecycleHook` for the events it cares about; the dispatcher fires events; services that don't care declare explicit `Ok(())` no-ops. New services register a hook in DI and inherit all four events for free. + +## The trait + +```rust +// src/application/ports/user_lifecycle.rs +#[async_trait] +pub trait UserLifecycleHook: Send + Sync { + fn name(&self) -> &'static str; + + async fn on_user_created(&self, user: &User) + -> Result<(), DomainError>; + + async fn on_user_login(&self, user: &User) + -> Result<(), DomainError>; + + async fn on_user_logout(&self, user: &User, reason: LogoutReason) + -> Result<(), DomainError>; + + async fn on_user_deleted(&self, user: &User, mode: DeletionMode) + -> Result<(), DomainError>; +} +``` + +Two enums frame the trait: + +```rust +pub enum LogoutReason { + UserInitiated, // explicit logout + SessionExpired, // TTL hit + AdminRevoked, // admin-initiated single-session revoke + AccountDisabled, // user.active flipped to FALSE → sessions revoked + PasswordChanged, // sibling sessions invalidated by password change + TokenReused, // session-family reuse detection +} + +pub enum DeletionMode { + AdminDelete, // admin deletes via UI; resources go to trash + GdprPurge, // GDPR right-to-erasure; hard-delete everything +} +``` + +**No default impls.** Every implementor must declare all four methods explicitly. Use `Ok(())` for events you don't care about. This forces conscious acknowledgement of every lifecycle event rather than silent inheritance — same convention as `FileLifecycleHook`. + +## Dispatcher semantics + +`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 | + +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. + +```text + ┌──────────────────────────────────────────────────┐ + │ AuthApplicationService │ + │ register / login / logout / delete │ + └──────────────────────┬───────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────┐ + │ UserLifecycleService::dispatch_* │ + │ created / login / logout / deleted │ + └──────────────────────┬───────────────────────────┘ + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + AuditLifecycleHook HomeFolderHook AuthzCacheHook … + (PR 1 only) (PR 3) (PR 4) +``` + +## Owner-located convention + +Each concrete hook impl lives **next to the service that owns the work**, not in a centralised `lifecycle/` directory. + +Examples (PR plan): + +- `HomeFolderLifecycleHook` lives in `src/application/services/folder_service.rs` — same module as `FolderService`, owner of home-folder policy. +- `AuthzCacheLifecycleHook` lives in `src/infrastructure/services/pg_acl_engine.rs` — same module as the Moka cache it invalidates. +- `AuditLifecycleHook` lives in `src/application/services/user_lifecycle_service.rs` (with the dispatcher) — cross-cutting, no domain owner. + +This mirrors how `FileLifecycleHook` impls are placed: `ThumbnailRefreshHook` lives in `thumbnail_service.rs`, the audio metadata impl lives in `audio_metadata_service.rs`. A future maintainer reading the folder service sees the lifecycle reactions next to the rest of the folder logic — no jumping between modules to understand why a folder gets created on login. + +## Tips for implementors + +These are codified in the module-level docstring of `application/ports/user_lifecycle.rs` so they show up in IDE hover. + +1. **First-ever login detection.** `on_user_login` fires *before* `user.register_login()` is called, so `user.last_login_at().is_none()` is a reliable "this is the user's first login since account creation" signal. Use it for welcome emails, one-shot default-resource seeding, "complete your profile" prompts. + +2. **Idempotency is mandatory.** `on_user_login` fires on every successful authentication, not just the first. A hook that creates a resource must check whether the resource already exists before creating it. Cache invalidation, audit deduplication, etc., must all tolerate redundant calls. + +3. **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 subsequent investigation can identify the user. The next successful login's `on_user_login` will retry idempotently. + +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. + +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. + +## Concrete hooks shipped today + +| 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. Co-located because audit is cross-cutting with no domain owner. | + +Subsequent PRs add: + +- `HomeFolderLifecycleHook` (PR 3) — owns home-folder provisioning; replaces the four eager `create_personal_folder` calls + the self-heal. +- `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. + +## Future events (NOT shipped — design door) + +These events are reserved for situations that don't exist yet but probably will. Adding a method to the trait costs every hook impl a new no-op forever, so we don't add them speculatively. Each row lists what would force the addition. + +| Future event | Why someone might want it | What would force adding it | +|---|---|---| +| `on_user_password_changed` | Notify the user via email; invalidate cached credentials; trigger TOTP re-enrolment | A per-user notification service. Today the existing `revoke_all_user_sessions` cascade fires `on_user_logout(PasswordChanged)` for each session — sufficient for current consumers. | +| `on_user_role_changed` | Audit promotion to admin; revoke admin-only sessions on demotion | A multi-role system. Today only `admin` / `user` exist and the one-liner audit log at the admin handler covers it. | +| `on_user_email_changed` | External users: re-verify the new email via magic-link; notify both old and new addresses | When external users start changing their email. Today email is immutable. | +| `on_user_avatar_changed` | Bust thumbnail caches; sync to federated servers (OCM) | When OCM federation ships. | +| `on_user_disabled` / `on_user_enabled` | Audit-distinguishable state changes; pause per-user scheduled jobs | When per-user scheduled jobs land. Today `on_user_logout(AccountDisabled)` covers the only consumer. | +| `on_user_external_to_internal_converted` | Welcome email; pre-provision internal-only resources at conversion time | If admins routinely promote external users and the next-login lag is unacceptable. Today idempotent `on_user_login` handles conversion fine. | +| `on_user_2fa_enabled` / `on_user_2fa_disabled` | Audit; force re-login of other sessions | When 2FA ships. | + +**Rule of thumb for adding any of these**: pair the addition with a default `Ok(())` body (one-time exception to the "no defaults" rule) so existing hooks don't need to declare it. State in the docstring whether the event is await-or-spawn and whether `Err` aborts. + +## File map + +| Concern | Module | +|---|---| +| Trait + `LogoutReason` + `DeletionMode` enums + tips | `src/application/ports/user_lifecycle.rs` | +| Dispatcher + `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` | +| Wire-in: created / login / logout / deleted | `src/application/services/auth_application_service.rs` | +| DI registration | `src/common/di.rs` (constructs the dispatcher) + `src/infrastructure/auth_factory.rs` (threads it into `AuthApplicationService`) | diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 8988aad0..c79f248c 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -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; diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs new file mode 100644 index 00000000..c37b8fe8 --- /dev/null +++ b/src/application/ports/user_lifecycle.rs @@ -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`. 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>; +} diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index bba3bea5..b334d29e 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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, token_service: Arc, folder_service: Option>, + /// Dispatcher for user-lifecycle events. `None` only in tests that don't + /// exercise the lifecycle path; production DI always wires this. + user_lifecycle: Option>, /// Path to the storage directory, used for disk-space–aware quota calculation storage_path: PathBuf, oidc: RwLock, @@ -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) -> Self { + self.user_lifecycle = Some(lifecycle); + self + } + /// Configures the OIDC service pub fn with_oidc(self, oidc_service: Arc, 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; diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 207736df..6bb1246e 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -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; diff --git a/src/application/services/user_lifecycle_service.rs b/src/application/services/user_lifecycle_service.rs new file mode 100644 index 00000000..12e91db1 --- /dev/null +++ b/src/application/services/user_lifecycle_service.rs @@ -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>` 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>, +} + +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) -> 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(()) + } +} diff --git a/src/common/di.rs b/src/common/di.rs index b0e747e1..8a5b50fe 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -691,12 +691,26 @@ impl AppServiceFactory { storage_usage_service = Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool)); + // User-lifecycle dispatcher. Audit hook is the only one + // registered in PR 1; subsequent PRs register + // HomeFolderLifecycleHook (PR 3), AuthzCacheLifecycleHook + // (PR 4), etc., each living next to the service that owns + // its work. Hook order is registration order — document + // dependencies inline if/when any arise. + let user_lifecycle = Arc::new( + crate::application::services::user_lifecycle_service::UserLifecycleService::new() + .with_hook(Arc::new( + crate::application::services::user_lifecycle_service::AuditLifecycleHook, + )), + ); + // Auth services if self.config.features.enable_auth { let services = crate::infrastructure::auth_factory::create_auth_services( &self.config, pool.clone(), Some(apps.folder_service_concrete.clone()), + user_lifecycle.clone(), ) .await .map_err(|e| { diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index 8caa422c..37d45f28 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -5,6 +5,7 @@ type Result = std::result::Result use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::folder_service::FolderService; +use crate::application::services::user_lifecycle_service::UserLifecycleService; use crate::common::config::AppConfig; use crate::common::di::AuthServices; use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository}; @@ -16,6 +17,7 @@ pub async fn create_auth_services( config: &AppConfig, pool: Arc, folder_service: Option>, + user_lifecycle: Arc, ) -> Result { // Create JWT token service (TokenServicePort implementation) let token_service: Arc = Arc::new(JwtTokenService::new( @@ -49,6 +51,10 @@ pub async fn create_auth_services( auth_app_service = auth_app_service.with_folder_service(folder_svc); } + // Wire the user-lifecycle dispatcher (carries AuditLifecycleHook today; + // PR 3+ register HomeFolderLifecycleHook, AuthzCacheLifecycleHook, …). + auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle); + // Configure OIDC service if enabled if config.oidc.enabled { tracing::info!(