refactor(userLifecycle): add user lifecycle, more clarety + better integration for the future
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user