use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions, MoveFolderDto, RenameFolderDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::folder_ports::FolderUseCase; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::domain::services::path_service::{StoragePath, validate_storage_name}; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use std::sync::Arc; use uuid::Uuid; /// Implementation of the use case for folder operations pub struct FolderService { folder_storage: Arc, authz: Arc, } impl FolderService { /// Creates a new folder service pub fn new(folder_storage: Arc, authz: Arc) -> Self { Self { folder_storage, authz, } } /// Helper: parse a folder id string into a `Resource::Folder`. Returns /// `DomainError::not_found` on parse error (anti-enumeration — the same /// error as "folder does not exist"). fn folder_resource(id: &str) -> Result { Uuid::parse_str(id) .map(Resource::Folder) .map_err(|_| DomainError::not_found("Folder", id)) } /// Creates a stub implementation for testing and middleware pub fn new_stub() -> impl FolderUseCase { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { async fn require_permission( &self, _caller_id: Uuid, _permission: Permission, _folder_id: &str, ) -> Result<(), DomainError> { Ok(()) } async fn create_folder_with_perms( &self, _dto: CreateFolderDto, _user_id: Uuid, ) -> Result { Ok(FolderDto::empty()) } async fn get_folder(&self, _id: &str) -> Result { Ok(FolderDto::empty()) } async fn get_folder_with_perms( &self, _id: &str, _caller_id: Uuid, ) -> Result { Ok(FolderDto::empty()) } async fn get_folder_by_path(&self, _path: &str) -> Result { Ok(FolderDto::empty()) } async fn list_folders( &self, _parent_id: Option<&str>, ) -> Result, DomainError> { Ok(vec![]) } async fn list_folders_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, ) -> Result, DomainError> { Ok(vec![]) } async fn list_folders_paginated( &self, _parent_id: Option<&str>, _pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result< crate::application::dtos::pagination::PaginatedResponseDto, DomainError, > { Ok( crate::application::dtos::pagination::PaginatedResponseDto::new( vec![], 0, 10, 0, ), ) } async fn list_folders_paginated_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, _pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result< crate::application::dtos::pagination::PaginatedResponseDto, DomainError, > { Ok( crate::application::dtos::pagination::PaginatedResponseDto::new( vec![], 0, 10, 0, ), ) } async fn rename_folder_with_perms( &self, _id: &str, _dto: RenameFolderDto, _caller_id: Uuid, ) -> Result { Ok(FolderDto::empty()) } async fn move_folder_with_perms( &self, _id: &str, _dto: MoveFolderDto, _caller_id: Uuid, ) -> Result { Ok(FolderDto::empty()) } async fn delete_folder_with_perms( &self, _id: &str, _caller_id: Uuid, ) -> Result<(), DomainError> { Ok(()) } async fn create_home_folder( &self, _user_id: Uuid, _name: String, ) -> Result { Ok(FolderDto::empty()) } } FolderServiceStub } } impl FolderUseCase for FolderService { /// Verifies the caller has the given permition on a resource /// `folder_id`. `None` is the caller's root namespace and always allowed. /// /// Returns `Ok(())` when permitted, `DomainError::not_found(...)` when not /// (anti-enumeration — same error as "folder doesn't exist"). /// /// Used by handlers that need a fail-fast pre-check BEFORE spooling /// large request bodies (file upload, chunked upload). The authoritative /// check happens again inside the upload/management services before any /// DB write — this is a UX/resource optimization, not a security boundary. async fn require_permission( &self, caller_id: Uuid, permission: Permission, folder_id: &str, ) -> Result<(), DomainError> { let resource = Self::folder_resource(folder_id)?; self.authz .require(Subject::User(caller_id), permission, resource) .await } /// Creates a new folder async fn create_folder_with_perms( &self, dto: CreateFolderDto, caller_id: Uuid, ) -> Result { if let Err(reason) = validate_storage_name(&dto.name) { return Err(DomainError::validation_error(format!( "Invalid folder name '{}': {reason}", dto.name ))); } let Some(parent_id) = dto.parent_id.as_deref() else { return Err(DomainError::validation_error( "Root folder creation is reserved for registration", )); }; let parent_resource = Self::folder_resource(parent_id)?; self.authz .require( Subject::User(caller_id), Permission::Create, parent_resource, ) .await?; let folder = self .folder_storage .create_folder(dto.name, dto.parent_id) .await?; Ok(FolderDto::from(folder)) } /// Creates a root-level home folder for a user during registration. async fn create_home_folder( &self, user_id: Uuid, name: String, ) -> Result { let folder = self .folder_storage .create_home_folder(user_id, name) .await .map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to create home folder: {}", e), ) })?; Ok(FolderDto::from(folder)) } async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { let folders = self.folder_storage.list_subtree_folders(folder_id).await?; Ok(folders.into_iter().map(FolderDto::from).collect()) } /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result { let folder = self.folder_storage.get_folder(id).await.map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to get folder with ID: {}: {}", id, e), ) })?; Ok(FolderDto::from(folder)) } /// Gets a folder by its ID, enforcing that `caller_id` has `Read` access /// (via ownership or a grant — including cascading from ancestor folders). async fn get_folder_with_perms( &self, id: &str, caller_id: Uuid, ) -> Result { self.authz .require( Subject::User(caller_id), Permission::Read, Self::folder_resource(id)?, ) .await?; self.get_folder(id).await } /// Gets a folder by its path async fn get_folder_by_path(&self, path: &str) -> Result { // Convert the string path to StoragePath let storage_path = StoragePath::from_string(path); let folder = self .folder_storage .get_folder_by_path(&storage_path) .await .map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to get folder at path: {}: {}", path, e), ) })?; Ok(FolderDto::from(folder)) } /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { let folders = self .folder_storage .list_folders(parent_id) .await .map_err(|e| { tracing::warn!("errror while fetching folders {}", e); DomainError::internal_error( "FolderStorage", format!("Failed to list folders in parent: {:?}: {}", parent_id, e), ) })?; // Convert to DTOs Ok(folders.into_iter().map(FolderDto::from).collect()) } /// Lists folders scoped to a specific owner. /// /// **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>, caller_id: Uuid, ) -> Result, DomainError> { if let Some(parent_id_unwrapped) = parent_id { // check authorisation self.authz .require( Subject::User(caller_id), Permission::Read, Self::folder_resource(parent_id_unwrapped)?, ) .await?; return self.list_folders(parent_id).await; } // 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()) } /// Lists folders with pagination async fn list_folders_paginated( &self, parent_id: Option<&str>, pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result, DomainError> { let pagination = pagination.validate_and_adjust(); let (folders, total_items) = self .folder_storage .list_folders_paginated(parent_id, pagination.offset(), pagination.limit(), true) .await .map_err(|e| { DomainError::internal_error( "FolderStorage", format!( "Failed to list folders with pagination in parent: {:?}: {}", parent_id, e ), ) })?; let total = total_items.unwrap_or(folders.len()); let response = crate::application::dtos::pagination::PaginatedResponseDto::new( folders.into_iter().map(FolderDto::from).collect(), pagination.page, pagination.page_size, total, ); Ok(response) } /// Lists folders with pagination, scoped to a specific owner. async fn list_folders_paginated_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, pagination: &crate::application::dtos::pagination::PaginationRequestDto, ) -> Result, DomainError> { let pagination = pagination.validate_and_adjust(); if let Some(parent_id_unwrapped) = parent_id { self.authz .require( Subject::User(owner_id), Permission::Read, Self::folder_resource(parent_id_unwrapped)?, ) .await?; return self.list_folders_paginated(parent_id, &pagination).await; } else { let (folders, total_items) = self .folder_storage .list_folders_by_owner_paginated( parent_id, owner_id, pagination.offset(), pagination.limit(), true, ) .await .map_err(|e| { DomainError::internal_error( "FolderStorage", format!( "Failed to list folders for owner '{}' with pagination in parent {:?}: {}", owner_id, parent_id, e ), ) })?; let total = total_items.unwrap_or(folders.len()); let response = crate::application::dtos::pagination::PaginatedResponseDto::new( folders.into_iter().map(FolderDto::from).collect(), pagination.page, pagination.page_size, total, ); Ok(response) } } /// Renames a folder after verifying the caller has `Update` permission. async fn rename_folder_with_perms( &self, id: &str, dto: RenameFolderDto, caller_id: Uuid, ) -> Result { if let Err(reason) = validate_storage_name(&dto.name) { return Err(DomainError::validation_error(format!( "Invalid folder name '{}': {reason}", dto.name ))); } self.authz .require( Subject::User(caller_id), Permission::Update, Self::folder_resource(id)?, ) .await?; let folder = self .folder_storage .rename_folder(id, dto.name) .await .map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to rename folder with ID: {}: {}", id, e), ) })?; Ok(FolderDto::from(folder)) } /// Moves a folder to a new parent. Requires `Update` on the source and /// `Create` on the destination parent (if any). async fn move_folder_with_perms( &self, id: &str, dto: MoveFolderDto, caller_id: Uuid, ) -> Result { let source_resource = Self::folder_resource(id)?; self.authz .require( Subject::User(caller_id), Permission::Update, source_resource, ) .await?; if let Some(parent_id) = &dto.parent_id { // Cannot move a folder into itself (cycle guard). if parent_id == id { return Err(DomainError::new( ErrorKind::InvalidInput, "Folder", "Cannot move a folder into itself", )); } let parent_resource = Self::folder_resource(parent_id)?; self.authz .require( Subject::User(caller_id), Permission::Create, parent_resource, ) .await?; // TODO: full descendant-cycle check (moving a folder into one of its own descendants) } let parent_ref = dto.parent_id.as_deref(); let folder = self .folder_storage .move_folder(id, parent_ref) .await .map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to move folder with ID: {}: {}", id, e), ) })?; Ok(FolderDto::from(folder)) } /// Deletes a folder after verifying the caller has `Delete` permission. /// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants` /// rows targeting the deleted folder automatically. async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { self.authz .require( Subject::User(caller_id), Permission::Delete, Self::folder_resource(id)?, ) .await?; self.folder_storage.delete_folder(id).await.map_err(|e| { DomainError::internal_error( "FolderStorage", format!("Failed to delete folder with ID: {}: {}", id, e), ) }) } } // ── FolderService — cursor-paginated resource listing ──────────────────────── impl FolderService { /// Cursor-paginated listing of sub-folders **and** files inside `parent_id`. /// /// Enforces `Permission::Read` on the parent folder before querying. /// `order_by` controls both the SQL `ORDER BY` and the cursor encoding. /// `kinds` filters the result to only the specified resource types. pub async fn list_resources_paged_with_perms( &self, parent_id: &str, caller_id: Uuid, opts: ListResourcesOptions<'_>, ) -> Result<(Vec, Option), DomainError> { // 1. AuthZ — same check as list_folders_with_perms self.authz .require( Subject::User(caller_id), Permission::Read, Self::folder_resource(parent_id)?, ) .await?; let pid = Uuid::parse_str(parent_id).map_err(|_| DomainError::not_found("Folder", parent_id))?; let ListResourcesOptions { limit, cursor, order_by, kinds, reverse, } = opts; // 2. Fetch limit+1 rows so we can detect has_next let mut rows = self .folder_storage .list_resources_paged(pid, limit + 1, cursor.as_ref(), order_by, kinds, reverse) .await?; // 3. Detect has_next, build encoded next cursor let next_cursor = if rows.len() > limit { let last = &rows[limit - 1]; let c = build_folder_resource_cursor(last, order_by, reverse); rows.truncate(limit); Some(c.encode()) } else { None }; 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: Option<&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 = match username { Some(u) => format!("My Folder - {}", u), None => format!("My Folder - {}", user_id), }; 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. /// `reverse` is stored in the cursor so subsequent pages use the same order. fn build_folder_resource_cursor( row: &FolderResourceRow, order_by: &str, reverse: bool, ) -> FolderResourceCursor { match order_by { "type" => FolderResourceCursor { order_by: "type".to_owned(), resource_id: row.id, sort_str: Some(row.sort_str.clone()), sort_int: Some(row.type_order), sort_ts: None, reverse, }, "modified_at" => FolderResourceCursor { order_by: "modified_at".to_owned(), resource_id: row.id, sort_str: None, sort_int: None, sort_ts: Some(row.modified_at), reverse, }, "created_at" => FolderResourceCursor { order_by: "created_at".to_owned(), resource_id: row.id, sort_str: None, sort_int: None, sort_ts: Some(row.created_at), reverse, }, "size" => FolderResourceCursor { order_by: "size".to_owned(), resource_id: row.id, sort_str: None, sort_int: Some(row.size), sort_ts: None, reverse, }, _ => FolderResourceCursor { // "name" (default): sort_int = folder_first (0 or 1) order_by: "name".to_owned(), resource_id: row.id, sort_str: Some(row.sort_str.clone()), sort_int: Some(i64::from(row.folder_first)), sort_ts: None, reverse, }, } } // ───────────────────────────────────────────────────────────────────────────── // 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, _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DomainError> { // For both DeletionMode variants today the FK CASCADE on // `storage.folders.user_id` (and downstream files/blobs) // removes the home folder + contents when the user row goes. // The hook emits a per-mode tracing event so audit can tell // AdminDelete (currently recoverable only via DB-level rollback // before commit) from GdprPurge (no sweeper exists yet — the // variant is reserved for a future PR that adds retention). // // The `tx` is provided per the trait contract but unused here: // emitting a tracing event doesn't require DB access. Future // policy (trash with retention) would write to `storage.trash` // inside this same tx. tracing::info!( target: "user_lifecycle", hook = "home_folder", user_id = %user.id(), mode = ?mode, "Home folder will be removed via FK CASCADE on user delete" ); Ok(()) } }