feat(userLifecycle): migrate create_personal_folder() use now on_user_login on_user_created (only if user is not external)

This commit is contained in:
Edouard Vanbelle
2026-06-01 16:05:02 +02:00
parent e130842bfc
commit d81f5dbe48
5 changed files with 209 additions and 138 deletions
@@ -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<SessionPgRepository>,
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.
/// HomeFolderLifecycleHook (registered on this dispatcher) owns the
/// per-user folder provisioning that AuthApplicationService used to do
/// inline pre-PR 3.
user_lifecycle: Option<Arc<UserLifecycleService>>,
/// 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<FolderService>) -> 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)
+152 -55
View File
@@ -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<bool, DomainError> {
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<FolderService>,
}
impl HomeFolderLifecycleHook {
pub fn new(folder_service: Arc<FolderService>) -> 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(())
}
}