feat(authz): check permission on read handlers + check create permission on folder
This commit is contained in:
@@ -36,12 +36,25 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
resource: Resource,
|
||||
) -> Result<(), DomainError> {
|
||||
if self.check(subject, permission, resource).await? {
|
||||
tracing::debug!(
|
||||
"👮🏻♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
};
|
||||
// log it for audit
|
||||
tracing::info!(
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Err(DomainError::not_found(kind, id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::application::services::file_management_service::FileManagementService
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::file_upload_service::FileUploadService;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::Permission;
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Upload port
|
||||
@@ -119,7 +120,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
///
|
||||
/// Returns `NotFound` if the file does not exist **or** belongs to
|
||||
/// another user. All user-facing handlers should use this method.
|
||||
async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError>;
|
||||
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// Gets a file by its path (for WebDAV)
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
|
||||
@@ -131,7 +132,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
///
|
||||
/// Uses SQL-level `AND user_id` filtering — no in-memory post-filter.
|
||||
/// All user-facing list handlers should use this method.
|
||||
async fn list_files_owned(
|
||||
async fn list_files_with_perms(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
@@ -144,7 +145,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Gets file content as a stream, enforcing that `caller_id` is the owner.
|
||||
async fn get_file_stream_owned(
|
||||
async fn get_file_stream_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
@@ -166,7 +167,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
///
|
||||
/// Verifies `caller_id` owns the file before returning content.
|
||||
/// All user-facing download handlers should use this.
|
||||
async fn get_file_optimized_owned(
|
||||
async fn get_file_optimized_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
@@ -198,7 +199,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
|
||||
|
||||
/// Ownership-scoped range stream — verifies caller owns the file first.
|
||||
async fn get_file_range_stream_owned(
|
||||
async fn get_file_range_stream_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
@@ -238,7 +239,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
///
|
||||
/// Used by streaming WebDAV PROPFIND so that each user only sees their
|
||||
/// own files, even in shared folder_id namespaces.
|
||||
async fn list_files_batch_for_owner(
|
||||
async fn list_files_batch_with_perms(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
@@ -256,6 +257,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
|
||||
|
||||
/// Primary port for file management operations
|
||||
pub trait FileManagementUseCase: Send + Sync + 'static {
|
||||
async fn has_permission(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
file_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Moves a file, enforcing that `caller_id` is the owner.
|
||||
async fn move_file_with_perms(
|
||||
&self,
|
||||
|
||||
@@ -6,8 +6,16 @@ use crate::application::dtos::folder_dto::{
|
||||
};
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::Permission;
|
||||
|
||||
pub trait FolderUseCase: Send + Sync + 'static {
|
||||
async fn has_permission(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
folder_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Creates a new folder
|
||||
async fn create_folder_with_perms(
|
||||
&self,
|
||||
@@ -36,7 +44,7 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
|
||||
/// Lists folders scoped to a specific owner (for user-facing endpoints).
|
||||
/// At root level, only returns folders belonging to this user.
|
||||
async fn list_folders_for_owner(
|
||||
async fn list_folders_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
@@ -50,7 +58,7 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||
|
||||
/// Lists folders with pagination, scoped to a specific owner.
|
||||
async fn list_folders_for_owner_paginated(
|
||||
async fn list_folders_paginated_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
|
||||
@@ -330,7 +330,7 @@ impl BatchOperationService {
|
||||
let retrieval = self.file_retrieval.clone();
|
||||
|
||||
async move {
|
||||
let get_result = retrieval.get_file_owned(&file_id, user_id).await;
|
||||
let get_result = retrieval.get_file_with_perms(&file_id, user_id).await;
|
||||
(file_id, get_result)
|
||||
}
|
||||
}))
|
||||
@@ -717,7 +717,11 @@ impl BatchOperationService {
|
||||
|
||||
// ── Add individual files at the root of the ZIP ──────────────────
|
||||
for file_id in &file_ids {
|
||||
match self.file_retrieval.get_file_owned(file_id, user_id).await {
|
||||
match self
|
||||
.file_retrieval
|
||||
.get_file_with_perms(file_id, user_id)
|
||||
.await
|
||||
{
|
||||
Ok(file_dto) => {
|
||||
if let Err(e) = self
|
||||
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id)
|
||||
@@ -790,7 +794,7 @@ impl BatchOperationService {
|
||||
|
||||
let stream = self
|
||||
.file_retrieval
|
||||
.get_file_stream_owned(file_id, caller_id)
|
||||
.get_file_stream_with_perms(file_id, caller_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?;
|
||||
let mut stream = std::pin::Pin::from(stream);
|
||||
|
||||
@@ -225,6 +225,18 @@ impl FileManagementService {
|
||||
}
|
||||
|
||||
impl FileManagementUseCase for FileManagementService {
|
||||
async fn has_permission(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
file_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), permission, Resource::File(uuid))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn move_file_with_perms(
|
||||
&self,
|
||||
file_id: &str,
|
||||
|
||||
@@ -64,6 +64,8 @@ impl FileRetrievalService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
|
||||
/// Helper: require the caller has `perm` on the given file id.
|
||||
/// Fail-closed if no engine was injected (stub/test path).
|
||||
async fn require_file(
|
||||
@@ -81,7 +83,25 @@ impl FileRetrievalService {
|
||||
.await
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
/// Engine check for a target folder. `None` is allowed (root namespace,
|
||||
/// implicitly owned by the caller).
|
||||
async fn require_target_folder_perm(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
perm: Permission,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let Some(target) = folder_id else {
|
||||
return Ok(());
|
||||
};
|
||||
let authz = self.authz.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
||||
})?;
|
||||
let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?;
|
||||
authz
|
||||
.require(Subject::User(caller_id), perm, Resource::Folder(uuid))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Try to transcode image content to WebP and return transcoded variant.
|
||||
async fn try_transcode(
|
||||
@@ -229,12 +249,13 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
// FIXME no authorisation at all
|
||||
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
|
||||
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
|
||||
// NOTE: This method does NOT perform any authorization check. Callers
|
||||
@@ -256,16 +277,24 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
async fn list_files_owned(
|
||||
async fn list_files_with_perms(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_for_owner(folder_id, owner_id)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
} else {
|
||||
// no folder id, get owners's files' root
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_for_owner(folder_id, owner_id)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
@@ -275,7 +304,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
self.file_read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
async fn get_file_stream_owned(
|
||||
async fn get_file_stream_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
@@ -297,7 +326,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_file_optimized_owned(
|
||||
async fn get_file_optimized_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
@@ -333,7 +362,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
async fn get_file_range_stream_owned(
|
||||
async fn get_file_range_stream_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
@@ -344,6 +373,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
// TODO: check: no permission check
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
@@ -366,13 +396,24 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
async fn list_files_batch_for_owner(
|
||||
async fn list_files_batch_with_perms(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.await?;
|
||||
return Ok(files.into_iter().map(FileDto::from).collect());
|
||||
}
|
||||
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch_for_owner(folder_id, owner_id, offset, limit)
|
||||
|
||||
@@ -41,6 +41,14 @@ impl FolderService {
|
||||
struct FolderServiceStub;
|
||||
|
||||
impl FolderUseCase for FolderServiceStub {
|
||||
async fn has_permission(
|
||||
&self,
|
||||
_caller_id: Uuid,
|
||||
_permission: Permission,
|
||||
_folder_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn create_folder_with_perms(
|
||||
&self,
|
||||
_dto: CreateFolderDto,
|
||||
@@ -72,7 +80,7 @@ impl FolderService {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_for_owner(
|
||||
async fn list_folders_with_perms(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
@@ -98,7 +106,7 @@ impl FolderService {
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_folders_for_owner_paginated(
|
||||
async fn list_folders_paginated_with_perms(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
@@ -157,6 +165,28 @@ impl FolderService {
|
||||
}
|
||||
|
||||
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 has_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,
|
||||
@@ -271,6 +301,7 @@ impl FolderUseCase for FolderService {
|
||||
.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),
|
||||
@@ -283,59 +314,77 @@ impl FolderUseCase for FolderService {
|
||||
|
||||
/// Lists folders scoped to a specific owner.
|
||||
/// Self-healing: if listing root folders and none exist, creates a home folder.
|
||||
async fn list_folders_for_owner(
|
||||
async fn list_folders_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner(parent_id, owner_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||
owner_id, parent_id, e
|
||||
),
|
||||
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)?,
|
||||
)
|
||||
})?;
|
||||
|
||||
// Self-healing: if listing root folders and none exist, create a home folder
|
||||
// This ensures the frontend always gets a valid userHomeFolderId
|
||||
if parent_id.is_none() && folders.is_empty() {
|
||||
tracing::info!(
|
||||
"No root folders found for user {}, creating home folder automatically",
|
||||
owner_id
|
||||
);
|
||||
let owner_id_short = {
|
||||
let s = owner_id.to_string();
|
||||
s[..8.min(s.len())].to_string()
|
||||
};
|
||||
let folder_name = format!("My Folder - {}", owner_id_short);
|
||||
match self
|
||||
.await?;
|
||||
return self.list_folders(parent_id).await;
|
||||
} else {
|
||||
// No parent defined grab user's homes
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.create_home_folder(owner_id, folder_name.clone())
|
||||
.list_folders_by_owner(parent_id, caller_id)
|
||||
.await
|
||||
{
|
||||
Ok(home_folder) => {
|
||||
tracing::info!(
|
||||
"Created home folder '{}' for user {}",
|
||||
folder_name,
|
||||
owner_id
|
||||
);
|
||||
return Ok(vec![FolderDto::from(home_folder)]);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create home folder for user {}: {}", owner_id, e);
|
||||
// Return empty list rather than failing - user might not have storage quota, etc.
|
||||
.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())
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -373,7 +422,7 @@ impl FolderUseCase for FolderService {
|
||||
}
|
||||
|
||||
/// Lists folders with pagination, scoped to a specific owner.
|
||||
async fn list_folders_for_owner_paginated(
|
||||
async fn list_folders_paginated_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
@@ -382,7 +431,17 @@ impl FolderUseCase for FolderService {
|
||||
{
|
||||
let pagination = pagination.validate_and_adjust();
|
||||
|
||||
let (folders, total_items) = self
|
||||
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,
|
||||
@@ -402,16 +461,17 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
let total = total_items.unwrap_or(folders.len());
|
||||
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,
|
||||
);
|
||||
let response = crate::application::dtos::pagination::PaginatedResponseDto::new(
|
||||
folders.into_iter().map(FolderDto::from).collect(),
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
total,
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames a folder after verifying the caller has `Update` permission.
|
||||
|
||||
@@ -387,7 +387,7 @@ use crate::common::stubs::StubFileRetrievalUseCase;
|
||||
async fn stub_get_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub.get_file_owned("file-1", user_id).await;
|
||||
let result = stub.get_file_with_perms("file-1", user_id).await;
|
||||
assert!(result.is_ok(), "stub should return Ok for get_file_owned");
|
||||
}
|
||||
|
||||
@@ -396,7 +396,7 @@ async fn stub_get_file_optimized_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub
|
||||
.get_file_optimized_owned("file-1", user_id, true, false)
|
||||
.get_file_optimized_with_perms("file-1", user_id, true, false)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
|
||||
@@ -179,9 +179,9 @@ impl ShareBrowseService {
|
||||
) -> Result<FolderListingDto, DomainError> {
|
||||
let (folders_res, files_res) = tokio::join!(
|
||||
self.folder_service
|
||||
.list_folders_for_owner(Some(parent_folder_id), owner_id),
|
||||
.list_folders_with_perms(Some(parent_folder_id), owner_id),
|
||||
self.file_retrieval
|
||||
.list_files_owned(Some(parent_folder_id), owner_id),
|
||||
.list_files_with_perms(Some(parent_folder_id), owner_id),
|
||||
);
|
||||
Ok(FolderListingDto {
|
||||
folders: folders_res?,
|
||||
|
||||
@@ -6,18 +6,21 @@ use crate::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::trash_dto::TrashedItemDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,9 @@ pub struct TrashService {
|
||||
/// Content cache — invalidated when files are permanently deleted from trash.
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
|
||||
/// Authz engine
|
||||
authz: Arc<PgAclEngine>,
|
||||
|
||||
/// Number of days items should be kept in trash before automatic cleanup
|
||||
retention_days: u32,
|
||||
}
|
||||
@@ -71,6 +77,7 @@ impl TrashService {
|
||||
dedup_service: Arc<DedupService>,
|
||||
thumbnail_service: Option<Arc<ThumbnailService>>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
trash_repository,
|
||||
@@ -80,6 +87,7 @@ impl TrashService {
|
||||
dedup_service,
|
||||
thumbnail_service,
|
||||
content_cache,
|
||||
authz,
|
||||
retention_days,
|
||||
}
|
||||
}
|
||||
@@ -176,6 +184,7 @@ impl TrashUseCase for TrashService {
|
||||
Ok(dtos)
|
||||
}
|
||||
|
||||
// TODO: change item_type into Resource enum
|
||||
#[instrument(skip(self))]
|
||||
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> {
|
||||
info!(
|
||||
@@ -209,10 +218,23 @@ impl TrashUseCase for TrashService {
|
||||
"file" => {
|
||||
info!("Processing file to move to trash: {}", item_id);
|
||||
|
||||
// XXX: right now only owner can move to trash, need to improve
|
||||
|
||||
// Get the file — ownership-verified at SQL level.
|
||||
// Returns NotFound if the file does not exist OR belongs to
|
||||
// another user, preventing cross-user trash operations.
|
||||
debug!("Getting file data (owner-scoped): {}", item_id);
|
||||
|
||||
let file_id = Uuid::parse_str(item_id)
|
||||
.map_err(|_| DomainError::not_found("File", item_id))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Delete,
|
||||
Resource::File(file_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let file = match self
|
||||
.file_read_port
|
||||
.get_file_for_owner(item_id, user_id)
|
||||
@@ -236,6 +258,7 @@ impl TrashUseCase for TrashService {
|
||||
debug!("Original file path: {}", original_path);
|
||||
|
||||
// Create the trash item
|
||||
// FIXME: item will be created with user_id that mat not be the owner_id
|
||||
debug!("Creating TrashedItem object for the file");
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
@@ -286,6 +309,17 @@ impl TrashUseCase for TrashService {
|
||||
Ok(())
|
||||
}
|
||||
"folder" => {
|
||||
// check deletion permition
|
||||
let folder_id = Uuid::parse_str(item_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", item_id))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Delete,
|
||||
Resource::Folder(folder_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Get the folder and verify ownership.
|
||||
// Returns NotFound if the folder does not exist or belongs
|
||||
// to another user — prevents cross-user trash operations.
|
||||
@@ -301,18 +335,10 @@ impl TrashUseCase for TrashService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Ownership check — return NotFound (not Forbidden) to
|
||||
// prevent leaking whether the folder exists.
|
||||
if folder.owner_id() != Some(user_id) {
|
||||
return Err(DomainError::not_found(
|
||||
"Folder",
|
||||
format!("Folder not found: {}", item_id),
|
||||
));
|
||||
}
|
||||
|
||||
let original_path = folder.storage_path().to_string();
|
||||
|
||||
// Create the trash item
|
||||
// FIXME: item will be created with user_id that mat not be the owner_id
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
|
||||
Reference in New Issue
Block a user