refactor(userLifecycle): add user lifecycle, more clarety + better integration for the future
This commit is contained in:
Generated
+1
@@ -3652,6 +3652,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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`) |
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -5,6 +5,7 @@ type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>
|
||||
|
||||
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<PgPool>,
|
||||
folder_service: Option<Arc<FolderService>>,
|
||||
user_lifecycle: Arc<UserLifecycleService>,
|
||||
) -> Result<AuthServices> {
|
||||
// Create JWT token service (TokenServicePort implementation)
|
||||
let token_service: Arc<JwtTokenService> = 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!(
|
||||
|
||||
Reference in New Issue
Block a user