diff --git a/docs/architecture/user-lifecycle.md b/docs/architecture/user-lifecycle.md index 371cedab..eac87b2a 100644 --- a/docs/architecture/user-lifecycle.md +++ b/docs/architecture/user-lifecycle.md @@ -119,15 +119,29 @@ These are codified in the module-level docstring of `application/ports/user_life | 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. | +| `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher) | All four events: emits one `tracing::info!(target: "audit", event = "user.*", ...)` per call, with `is_external` as a field. Co-located because audit is cross-cutting with no domain owner. | +| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision "My Folder - {username}" via `FolderService::ensure_home_folder`. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: `Ok(())` for PR 3 — PR 4 adds the trash-vs-hard-delete policy based on `DeletionMode`. Owns the responsibility that pre-PR 3 was scattered across four eager `create_personal_folder` calls in `AuthApplicationService` and one self-heal at the folder-listing path. | 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. +### Worked example: brand-new user logs in for the first time + +1. Client POSTs `/api/auth/login` with valid credentials. +2. `AuthApplicationService::login()` validates the password against the stored Argon2 hash. +3. **Before** `user.register_login()` is called, the dispatcher fires `dispatch_login(&user)`. The user's `last_login_at` is still `None` from creation time. +4. `AuditLifecycleHook::on_user_login` runs first (registration order): emits `event = "user.login", user_id = ..., username = ..., is_external = false, first_login = true`. +5. `HomeFolderLifecycleHook::on_user_login` runs next: sees `!user.is_external()`, calls `FolderService::ensure_home_folder(uid, username)`. The service checks `list_folders_by_owner(None, uid)` — empty → creates `"My Folder - alice"`. Returns `Ok(true)` (newly created). +6. Dispatcher finishes. `user.register_login()` is now called, stamping `last_login_at` to the current time. +7. The session row is INSERTed; access + refresh tokens generated; response returned to the client. + +On the user's **second** login: same flow up through step 5, but `ensure_home_folder` finds the existing folder, returns `Ok(false)`, no-op. The `AuditLifecycleHook` still emits an event, but `first_login = false` this time. + +If the home folder gets deleted manually (e.g., SQL `DELETE FROM storage.folders WHERE user_id = $1`), the user's **next** login will re-create it — that's the safety-net behaviour the lifecycle hook contractually owns. + ## 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. diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 008c2b8c..2588b8c3 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -5,9 +5,7 @@ use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, 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}; @@ -73,9 +71,11 @@ pub struct AuthApplicationService { session_storage: Arc, 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. + /// HomeFolderLifecycleHook (registered on this dispatcher) owns the + /// per-user folder provisioning that AuthApplicationService used to do + /// inline pre-PR 3. user_lifecycle: Option>, /// Path to the storage directory, used for disk-space–aware quota calculation storage_path: PathBuf, @@ -101,7 +101,6 @@ impl AuthApplicationService { session_storage, password_hasher, token_service, - folder_service: None, user_lifecycle: None, storage_path, oidc: RwLock::new(OidcState { @@ -165,12 +164,6 @@ impl AuthApplicationService { } } - /// Configures the folder service, needed to create personal folders - pub fn with_folder_service(mut self, folder_service: Arc) -> Self { - self.folder_service = Some(folder_service); - 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. @@ -293,17 +286,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. + // Lifecycle: HomeFolderLifecycleHook handles personal-folder + // creation (was inlined here pre-PR 3); audit log + future + // provisioning steps land here too. 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; - tracing::info!("User registered: {}", created_user.id()); Ok(UserDto::from(created_user)) } @@ -379,14 +368,12 @@ impl AuthApplicationService { // Lifecycle: notify hooks. PR 3 moves home-folder creation into // HomeFolderLifecycleHook fired here. + // Lifecycle: HomeFolderLifecycleHook provisions the admin's + // home folder. Audit logs the creation event. 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; - tracing::info!( "Initial admin created via setup: {} ({})", username, @@ -923,20 +910,13 @@ impl AuthApplicationService { .await?; } - // Lifecycle: notify hooks. PR 3 moves home-folder creation into - // HomeFolderLifecycleHook fired here. + // Lifecycle: HomeFolderLifecycleHook handles the home-folder + // provisioning (idempotent + short-circuits on is_external). + // Audit logs the creation event. if let Some(lc) = &self.user_lifecycle { lc.dispatch_created(&created).await; } - // External users have no home folder by design. Internal users - // get one — PR 3 will move this provisioning into the lifecycle - // hook (which short-circuits on `is_external` itself). - if !created.is_external() { - self.create_personal_folder(&dto.username, created.id()) - .await; - } - tracing::info!( "Admin created user: {} ({}, is_external={})", dto.username, @@ -1397,18 +1377,15 @@ 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). + // Lifecycle: created (audit + home-folder provisioning) + + // login (no register_login() for a fresh OIDC user means + // `last_login_at` is naturally None → first-login detection + // works). HomeFolderLifecycleHook creates the home folder. 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; - tracing::info!( "OIDC user provisioned: {} (provider: {}, sub: {})", created_user.id(), @@ -1503,32 +1480,10 @@ impl AuthApplicationService { UserRole::User } - /// Helper to create a personal folder for a new user - async fn create_personal_folder(&self, username: &str, user_id: Uuid) { - if let Some(folder_service) = &self.folder_service { - let folder_name = format!("My Folder - {}", username); - match folder_service - .create_home_folder(user_id, folder_name.clone()) - .await - { - Ok(folder) => { - tracing::info!( - "Personal folder created for user {}: {} (ID: {})", - user_id, - folder.name, - folder.id - ); - } - Err(e) => { - tracing::error!( - "Failed to create personal folder for user {}: {}", - user_id, - e - ); - } - } - } - } + // `create_personal_folder` was removed in PR 3 of the + // UserLifecycleHook migration — home-folder provisioning is now + // owned by `HomeFolderLifecycleHook` in folder_service.rs and runs + // via `dispatch_created` / `dispatch_login`. } /// URL-safe base64 encoding without padding (RFC 4648 §5) diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 9e39e7e0..5d2e73f8 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -315,7 +315,12 @@ impl FolderUseCase for FolderService { } /// Lists folders scoped to a specific owner. - /// Self-healing: if listing root folders and none exist, creates a home folder. + /// + /// **Note (post PR 3):** the self-heal block that auto-created a + /// home folder when listing returned empty has been removed. + /// `HomeFolderLifecycleHook` (registered on `UserLifecycleService`) + /// now provisions the folder on `on_user_created` / `on_user_login`, + /// idempotently, so the listing path no longer needs to self-heal. async fn list_folders_with_perms( &self, parent_id: Option<&str>, @@ -331,62 +336,23 @@ impl FolderUseCase for FolderService { ) .await?; return self.list_folders(parent_id).await; - } else { - // No parent defined grab user's homes - let folders = self - .folder_storage - .list_folders_by_owner(parent_id, caller_id) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!( - "Failed to list folders for owner '{}' in parent {:?}: {}", - caller_id, parent_id, e - ), - ) - })?; - - if folders.is_empty() { - // Self-healing: if listing root folders and none exist, create a home folder - // This ensures the frontend always gets a valid userHomeFolderId - tracing::info!( - "No root folders found for user {}, creating home folder automatically", - caller_id - ); - let owner_id_short = { - let s = caller_id.to_string(); - s[..8.min(s.len())].to_string() - }; - // TODO: what about i18n ? - let folder_name = format!("My Folder - {}", owner_id_short); - match self - .folder_storage - .create_home_folder(caller_id, folder_name.clone()) - .await - { - Ok(home_folder) => { - tracing::info!( - "Created home folder '{}' for user {}", - folder_name, - caller_id - ); - return Ok(vec![FolderDto::from(home_folder)]); - } - Err(e) => { - tracing::warn!( - "Failed to create home folder for user {}: {}", - caller_id, - e - ); - // Return empty list rather than failing - user might not have storage quota, etc. - } - } - } - Ok(folders.into_iter().map(FolderDto::from).collect()) } + // No parent → list the user's root folders. + let folders = self + .folder_storage + .list_folders_by_owner(parent_id, caller_id) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!( + "Failed to list folders for owner '{}' in parent {:?}: {}", + caller_id, parent_id, e + ), + ) + })?; + Ok(folders.into_iter().map(FolderDto::from).collect()) } - // TODO: move self healing in other part (on account creation on or login ?) /// Lists folders with pagination async fn list_folders_paginated( @@ -637,6 +603,59 @@ impl FolderService { Ok((rows, next_cursor)) } + + /// Idempotently provision a home folder for a user. + /// + /// Returns `Ok(true)` if a folder was newly created, `Ok(false)` if the + /// user already had at least one root folder. + /// + /// **System-level operation** — bypasses authz because this runs on + /// the user's own behalf (during creation or login provisioning) at a + /// point where the caller may be the engine itself, not an HTTP user. + /// Callers must be inside trusted code paths (lifecycle hooks). + /// + /// Used by [`HomeFolderLifecycleHook`] on `on_user_created` and + /// `on_user_login`. Replaces the old self-heal at the listing path + /// and the four eager `create_personal_folder` calls in + /// `AuthApplicationService` (removed in the same PR). + pub async fn ensure_home_folder( + &self, + user_id: Uuid, + username: &str, + ) -> Result { + let existing = self + .folder_storage + .list_folders_by_owner(None, user_id) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("ensure_home_folder: list root folders: {}", e), + ) + })?; + if !existing.is_empty() { + return Ok(false); + } + + let folder_name = format!("My Folder - {}", username); + self.folder_storage + .create_home_folder(user_id, folder_name.clone()) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("ensure_home_folder: create: {}", e), + ) + })?; + tracing::info!( + target: "user_lifecycle", + hook = "home_folder", + user_id = %user_id, + folder_name = %folder_name, + "Home folder provisioned" + ); + Ok(true) + } } /// Build the next-page cursor from the last row of the current page. @@ -690,3 +709,81 @@ fn build_folder_resource_cursor( }, } } + +// ───────────────────────────────────────────────────────────────────────────── +// HomeFolderLifecycleHook +// +// Owns home-folder provisioning policy. Replaces: +// - the 4 eager `create_personal_folder` calls in AuthApplicationService +// (register / setup_create_admin / admin_create_user / OIDC JIT) +// - the self-heal at `list_folders_with_perms` when no root folders exist +// +// Lives in this file (not under a centralised `lifecycle/` directory) +// because the folder service owns home-folder policy — see the +// "owner-located convention" note in +// `docs/architecture/user-lifecycle.md`. +// ───────────────────────────────────────────────────────────────────────────── + +use async_trait::async_trait; + +use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; +use crate::domain::entities::user::User; + +/// Lifecycle hook: provisions and (in PR 4) deprovisions a user's home folder. +pub struct HomeFolderLifecycleHook { + folder_service: Arc, +} + +impl HomeFolderLifecycleHook { + pub fn new(folder_service: Arc) -> Self { + Self { folder_service } + } + + /// Idempotent provisioning shared by `on_user_created` and + /// `on_user_login`. External users are skipped per tip #2 in the + /// trait docstring. + async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> { + if user.is_external() { + return Ok(()); + } + // `ensure_home_folder` handles the "does the user already have a + // root folder?" check internally and is a no-op if so. + self.folder_service + .ensure_home_folder(user.id(), user.username()) + .await + .map(|_created| ()) + } +} + +#[async_trait] +impl UserLifecycleHook for HomeFolderLifecycleHook { + fn name(&self) -> &'static str { + "home_folder" + } + + async fn on_user_created(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + /// Login is the safety net — if `on_user_created` failed at any + /// earlier point (or the user was created in a flow that pre-dated + /// this hook), provisioning happens here on next login. + async fn on_user_login(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { + // Folders don't react to logout. Explicit no-op per the + // "no defaults" convention. + Ok(()) + } + + async fn on_user_deleted(&self, _user: &User, _mode: DeletionMode) -> Result<(), DomainError> { + // PR 4 will fill this in with the trash-vs-hard-delete policy + // (AdminDelete → trash with retention, GdprPurge → hard delete). + // For PR 3 the eager `DELETE FROM auth.users` cascades via the + // existing FK on storage.folders to remove the home folder, so a + // no-op here matches behaviour parity. + Ok(()) + } +} diff --git a/src/common/di.rs b/src/common/di.rs index 8a5b50fe..b7e609b2 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -691,25 +691,35 @@ 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. + // User-lifecycle dispatcher. Hook order is registration order; + // document dependencies inline if/when any arise. Today: + // 1. AuditLifecycleHook — fires first so the audit + // event is recorded even if a + // later hook errors out. + // 2. HomeFolderLifecycleHook — provisions the user's home + // folder on created/login (no-op + // for external users). + // Subsequent PRs register AuthzCacheLifecycleHook (PR 4) etc. let user_lifecycle = Arc::new( crate::application::services::user_lifecycle_service::UserLifecycleService::new() .with_hook(Arc::new( crate::application::services::user_lifecycle_service::AuditLifecycleHook, + )) + .with_hook(Arc::new( + crate::application::services::folder_service::HomeFolderLifecycleHook::new( + apps.folder_service_concrete.clone(), + ), )), ); - // Auth services + // Auth services. Folder service no longer threaded here — + // PR 3 moved home-folder provisioning into + // HomeFolderLifecycleHook, which already holds an Arc to the + // folder service via the user_lifecycle dispatcher. 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 diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index 37d45f28..42ce88a8 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -4,7 +4,6 @@ use std::sync::Arc; 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; @@ -16,7 +15,6 @@ use crate::infrastructure::services::password_hasher::Argon2PasswordHasher; pub async fn create_auth_services( config: &AppConfig, pool: Arc, - folder_service: Option>, user_lifecycle: Arc, ) -> Result { // Create JWT token service (TokenServicePort implementation) @@ -46,13 +44,10 @@ pub async fn create_auth_services( config.storage_path.clone(), ); - // Configure folder service if available - if let Some(folder_svc) = folder_service { - auth_app_service = auth_app_service.with_folder_service(folder_svc); - } - - // Wire the user-lifecycle dispatcher (carries AuditLifecycleHook today; - // PR 3+ register HomeFolderLifecycleHook, AuthzCacheLifecycleHook, …). + // Wire the user-lifecycle dispatcher. Home-folder provisioning is + // now handled by HomeFolderLifecycleHook (registered on the + // dispatcher in DI) — AuthApplicationService no longer needs a + // direct FolderService dependency for that path. auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle); // Configure OIDC service if enabled