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:
@@ -119,15 +119,29 @@ These are codified in the module-level docstring of `application/ports/user_life
|
|||||||
|
|
||||||
| Hook | Lives in | Responsibility |
|
| 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:
|
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.
|
- `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.
|
- `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.
|
- `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)
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ use crate::application::ports::auth_ports::{
|
|||||||
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
||||||
UserStoragePort,
|
UserStoragePort,
|
||||||
};
|
};
|
||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
|
||||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
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::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::OidcConfig;
|
use crate::common::config::OidcConfig;
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
@@ -73,9 +71,11 @@ pub struct AuthApplicationService {
|
|||||||
session_storage: Arc<SessionPgRepository>,
|
session_storage: Arc<SessionPgRepository>,
|
||||||
password_hasher: Arc<Argon2PasswordHasher>,
|
password_hasher: Arc<Argon2PasswordHasher>,
|
||||||
token_service: Arc<JwtTokenService>,
|
token_service: Arc<JwtTokenService>,
|
||||||
folder_service: Option<Arc<FolderService>>,
|
|
||||||
/// Dispatcher for user-lifecycle events. `None` only in tests that don't
|
/// Dispatcher for user-lifecycle events. `None` only in tests that don't
|
||||||
/// exercise the lifecycle path; production DI always wires this.
|
/// 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>>,
|
user_lifecycle: Option<Arc<UserLifecycleService>>,
|
||||||
/// Path to the storage directory, used for disk-space–aware quota calculation
|
/// Path to the storage directory, used for disk-space–aware quota calculation
|
||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
@@ -101,7 +101,6 @@ impl AuthApplicationService {
|
|||||||
session_storage,
|
session_storage,
|
||||||
password_hasher,
|
password_hasher,
|
||||||
token_service,
|
token_service,
|
||||||
folder_service: None,
|
|
||||||
user_lifecycle: None,
|
user_lifecycle: None,
|
||||||
storage_path,
|
storage_path,
|
||||||
oidc: RwLock::new(OidcState {
|
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
|
/// Configures the user-lifecycle dispatcher. Wired by the DI factory
|
||||||
/// after core services are up. PR 1: only AuditLifecycleHook is
|
/// after core services are up. PR 1: only AuditLifecycleHook is
|
||||||
/// registered, so calls without this configured silently no-op.
|
/// registered, so calls without this configured silently no-op.
|
||||||
@@ -293,17 +286,13 @@ impl AuthApplicationService {
|
|||||||
// Save user
|
// Save user
|
||||||
let created_user = self.user_storage.create_user(user).await?;
|
let created_user = self.user_storage.create_user(user).await?;
|
||||||
|
|
||||||
// Lifecycle: notify hooks (audit, future provisioning, etc.).
|
// Lifecycle: HomeFolderLifecycleHook handles personal-folder
|
||||||
// PR 3 will move the personal-folder creation below into a
|
// creation (was inlined here pre-PR 3); audit log + future
|
||||||
// HomeFolderLifecycleHook fired here; for now both run.
|
// provisioning steps land here too.
|
||||||
if let Some(lc) = &self.user_lifecycle {
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
lc.dispatch_created(&created_user).await;
|
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());
|
tracing::info!("User registered: {}", created_user.id());
|
||||||
Ok(UserDto::from(created_user))
|
Ok(UserDto::from(created_user))
|
||||||
}
|
}
|
||||||
@@ -379,14 +368,12 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||||
// HomeFolderLifecycleHook fired here.
|
// HomeFolderLifecycleHook fired here.
|
||||||
|
// Lifecycle: HomeFolderLifecycleHook provisions the admin's
|
||||||
|
// home folder. Audit logs the creation event.
|
||||||
if let Some(lc) = &self.user_lifecycle {
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
lc.dispatch_created(&created_user).await;
|
lc.dispatch_created(&created_user).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create personal folder for the admin
|
|
||||||
self.create_personal_folder(&username, created_user.id())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Initial admin created via setup: {} ({})",
|
"Initial admin created via setup: {} ({})",
|
||||||
username,
|
username,
|
||||||
@@ -923,20 +910,13 @@ impl AuthApplicationService {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
// Lifecycle: HomeFolderLifecycleHook handles the home-folder
|
||||||
// HomeFolderLifecycleHook fired here.
|
// provisioning (idempotent + short-circuits on is_external).
|
||||||
|
// Audit logs the creation event.
|
||||||
if let Some(lc) = &self.user_lifecycle {
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
lc.dispatch_created(&created).await;
|
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!(
|
tracing::info!(
|
||||||
"Admin created user: {} ({}, is_external={})",
|
"Admin created user: {} ({}, is_external={})",
|
||||||
dto.username,
|
dto.username,
|
||||||
@@ -1397,18 +1377,15 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
let created_user = self.user_storage.create_user(new_user).await?;
|
let created_user = self.user_storage.create_user(new_user).await?;
|
||||||
|
|
||||||
// Lifecycle: created (audit) + login (no register_login()
|
// Lifecycle: created (audit + home-folder provisioning) +
|
||||||
// for a fresh OIDC user means `last_login_at` is naturally
|
// login (no register_login() for a fresh OIDC user means
|
||||||
// None → first-login detection works).
|
// `last_login_at` is naturally None → first-login detection
|
||||||
|
// works). HomeFolderLifecycleHook creates the home folder.
|
||||||
if let Some(lc) = &self.user_lifecycle {
|
if let Some(lc) = &self.user_lifecycle {
|
||||||
lc.dispatch_created(&created_user).await;
|
lc.dispatch_created(&created_user).await;
|
||||||
lc.dispatch_login(&created_user).await;
|
lc.dispatch_login(&created_user).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create personal folder
|
|
||||||
self.create_personal_folder(&username, created_user.id())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"OIDC user provisioned: {} (provider: {}, sub: {})",
|
"OIDC user provisioned: {} (provider: {}, sub: {})",
|
||||||
created_user.id(),
|
created_user.id(),
|
||||||
@@ -1503,32 +1480,10 @@ impl AuthApplicationService {
|
|||||||
UserRole::User
|
UserRole::User
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to create a personal folder for a new user
|
// `create_personal_folder` was removed in PR 3 of the
|
||||||
async fn create_personal_folder(&self, username: &str, user_id: Uuid) {
|
// UserLifecycleHook migration — home-folder provisioning is now
|
||||||
if let Some(folder_service) = &self.folder_service {
|
// owned by `HomeFolderLifecycleHook` in folder_service.rs and runs
|
||||||
let folder_name = format!("My Folder - {}", username);
|
// via `dispatch_created` / `dispatch_login`.
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL-safe base64 encoding without padding (RFC 4648 §5)
|
/// URL-safe base64 encoding without padding (RFC 4648 §5)
|
||||||
|
|||||||
@@ -315,7 +315,12 @@ impl FolderUseCase for FolderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Lists folders scoped to a specific owner.
|
/// 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(
|
async fn list_folders_with_perms(
|
||||||
&self,
|
&self,
|
||||||
parent_id: Option<&str>,
|
parent_id: Option<&str>,
|
||||||
@@ -331,62 +336,23 @@ impl FolderUseCase for FolderService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
return self.list_folders(parent_id).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
|
/// Lists folders with pagination
|
||||||
async fn list_folders_paginated(
|
async fn list_folders_paginated(
|
||||||
@@ -637,6 +603,59 @@ impl FolderService {
|
|||||||
|
|
||||||
Ok((rows, next_cursor))
|
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.
|
/// 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-8
@@ -691,25 +691,35 @@ impl AppServiceFactory {
|
|||||||
storage_usage_service =
|
storage_usage_service =
|
||||||
Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool));
|
Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool));
|
||||||
|
|
||||||
// User-lifecycle dispatcher. Audit hook is the only one
|
// User-lifecycle dispatcher. Hook order is registration order;
|
||||||
// registered in PR 1; subsequent PRs register
|
// document dependencies inline if/when any arise. Today:
|
||||||
// HomeFolderLifecycleHook (PR 3), AuthzCacheLifecycleHook
|
// 1. AuditLifecycleHook — fires first so the audit
|
||||||
// (PR 4), etc., each living next to the service that owns
|
// event is recorded even if a
|
||||||
// its work. Hook order is registration order — document
|
// later hook errors out.
|
||||||
// dependencies inline if/when any arise.
|
// 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(
|
let user_lifecycle = Arc::new(
|
||||||
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
|
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 {
|
if self.config.features.enable_auth {
|
||||||
let services = crate::infrastructure::auth_factory::create_auth_services(
|
let services = crate::infrastructure::auth_factory::create_auth_services(
|
||||||
&self.config,
|
&self.config,
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
Some(apps.folder_service_concrete.clone()),
|
|
||||||
user_lifecycle.clone(),
|
user_lifecycle.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
|||||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
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::auth_application_service::AuthApplicationService;
|
||||||
use crate::application::services::folder_service::FolderService;
|
|
||||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::AppConfig;
|
use crate::common::config::AppConfig;
|
||||||
use crate::common::di::AuthServices;
|
use crate::common::di::AuthServices;
|
||||||
@@ -16,7 +15,6 @@ use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
|||||||
pub async fn create_auth_services(
|
pub async fn create_auth_services(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
pool: Arc<PgPool>,
|
pool: Arc<PgPool>,
|
||||||
folder_service: Option<Arc<FolderService>>,
|
|
||||||
user_lifecycle: Arc<UserLifecycleService>,
|
user_lifecycle: Arc<UserLifecycleService>,
|
||||||
) -> Result<AuthServices> {
|
) -> Result<AuthServices> {
|
||||||
// Create JWT token service (TokenServicePort implementation)
|
// Create JWT token service (TokenServicePort implementation)
|
||||||
@@ -46,13 +44,10 @@ pub async fn create_auth_services(
|
|||||||
config.storage_path.clone(),
|
config.storage_path.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure folder service if available
|
// Wire the user-lifecycle dispatcher. Home-folder provisioning is
|
||||||
if let Some(folder_svc) = folder_service {
|
// now handled by HomeFolderLifecycleHook (registered on the
|
||||||
auth_app_service = auth_app_service.with_folder_service(folder_svc);
|
// dispatcher in DI) — AuthApplicationService no longer needs a
|
||||||
}
|
// direct FolderService dependency for that path.
|
||||||
|
|
||||||
// Wire the user-lifecycle dispatcher (carries AuditLifecycleHook today;
|
|
||||||
// PR 3+ register HomeFolderLifecycleHook, AuthzCacheLifecycleHook, …).
|
|
||||||
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
||||||
|
|
||||||
// Configure OIDC service if enabled
|
// Configure OIDC service if enabled
|
||||||
|
|||||||
Reference in New Issue
Block a user