feat(authz): check permission on read handlers + check create permission on folder

This commit is contained in:
Edouard Vanbelle
2026-05-21 11:07:04 +02:00
parent cba9be8c21
commit 3362e277ab
21 changed files with 428 additions and 180 deletions
@@ -36,12 +36,25 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
resource: Resource, resource: Resource,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
if self.check(subject, permission, resource).await? { if self.check(subject, permission, resource).await? {
tracing::debug!(
"👮🏻‍♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
subject,
permission,
resource
);
Ok(()) Ok(())
} else { } else {
let (kind, id) = match resource { let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id), Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", 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())) Err(DomainError::not_found(kind, id.to_string()))
} }
} }
+14 -6
View File
@@ -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_retrieval_service::FileRetrievalService;
use crate::application::services::file_upload_service::FileUploadService; use crate::application::services::file_upload_service::FileUploadService;
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::services::authorization::Permission;
// ───────────────────────────────────────────────────── // ─────────────────────────────────────────────────────
// Upload port // Upload port
@@ -119,7 +120,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
/// ///
/// Returns `NotFound` if the file does not exist **or** belongs to /// Returns `NotFound` if the file does not exist **or** belongs to
/// another user. All user-facing handlers should use this method. /// 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) /// Gets a file by its path (for WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>; 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. /// Uses SQL-level `AND user_id` filtering — no in-memory post-filter.
/// All user-facing list handlers should use this method. /// All user-facing list handlers should use this method.
async fn list_files_owned( async fn list_files_with_perms(
&self, &self,
folder_id: Option<&str>, folder_id: Option<&str>,
owner_id: Uuid, 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>; ) -> 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. /// 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, &self,
id: &str, id: &str,
caller_id: Uuid, caller_id: Uuid,
@@ -166,7 +167,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
/// ///
/// Verifies `caller_id` owns the file before returning content. /// Verifies `caller_id` owns the file before returning content.
/// All user-facing download handlers should use this. /// All user-facing download handlers should use this.
async fn get_file_optimized_owned( async fn get_file_optimized_with_perms(
&self, &self,
id: &str, id: &str,
caller_id: Uuid, 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>; ) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Ownership-scoped range stream — verifies caller owns the file first. /// 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, &self,
id: &str, id: &str,
caller_id: Uuid, 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 /// Used by streaming WebDAV PROPFIND so that each user only sees their
/// own files, even in shared folder_id namespaces. /// own files, even in shared folder_id namespaces.
async fn list_files_batch_for_owner( async fn list_files_batch_with_perms(
&self, &self,
folder_id: Option<&str>, folder_id: Option<&str>,
owner_id: Uuid, owner_id: Uuid,
@@ -256,6 +257,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
/// Primary port for file management operations /// Primary port for file management operations
pub trait FileManagementUseCase: Send + Sync + 'static { 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. /// Moves a file, enforcing that `caller_id` is the owner.
async fn move_file_with_perms( async fn move_file_with_perms(
&self, &self,
+10 -2
View File
@@ -6,8 +6,16 @@ use crate::application::dtos::folder_dto::{
}; };
use crate::common::errors::DomainError; use crate::common::errors::DomainError;
use crate::domain::services::authorization::Permission;
pub trait FolderUseCase: Send + Sync + 'static { 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 /// Creates a new folder
async fn create_folder_with_perms( async fn create_folder_with_perms(
&self, &self,
@@ -36,7 +44,7 @@ pub trait FolderUseCase: Send + Sync + 'static {
/// Lists folders scoped to a specific owner (for user-facing endpoints). /// Lists folders scoped to a specific owner (for user-facing endpoints).
/// At root level, only returns folders belonging to this user. /// At root level, only returns folders belonging to this user.
async fn list_folders_for_owner( async fn list_folders_with_perms(
&self, &self,
parent_id: Option<&str>, parent_id: Option<&str>,
owner_id: Uuid, owner_id: Uuid,
@@ -50,7 +58,7 @@ pub trait FolderUseCase: Send + Sync + 'static {
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>; ) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
/// Lists folders with pagination, scoped to a specific owner. /// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_for_owner_paginated( async fn list_folders_paginated_with_perms(
&self, &self,
parent_id: Option<&str>, parent_id: Option<&str>,
owner_id: Uuid, owner_id: Uuid,
+7 -3
View File
@@ -330,7 +330,7 @@ impl BatchOperationService {
let retrieval = self.file_retrieval.clone(); let retrieval = self.file_retrieval.clone();
async move { 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) (file_id, get_result)
} }
})) }))
@@ -717,7 +717,11 @@ impl BatchOperationService {
// ── Add individual files at the root of the ZIP ────────────────── // ── Add individual files at the root of the ZIP ──────────────────
for file_id in &file_ids { 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) => { Ok(file_dto) => {
if let Err(e) = self if let Err(e) = self
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id) .add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id)
@@ -790,7 +794,7 @@ impl BatchOperationService {
let stream = self let stream = self
.file_retrieval .file_retrieval
.get_file_stream_owned(file_id, caller_id) .get_file_stream_with_perms(file_id, caller_id)
.await .await
.map_err(BatchOperationError::Domain)?; .map_err(BatchOperationError::Domain)?;
let mut stream = std::pin::Pin::from(stream); let mut stream = std::pin::Pin::from(stream);
@@ -225,6 +225,18 @@ impl FileManagementService {
} }
impl FileManagementUseCase for 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( async fn move_file_with_perms(
&self, &self,
file_id: &str, file_id: &str,
@@ -64,6 +64,8 @@ impl FileRetrievalService {
} }
} }
// ── private helpers ──────────────────────────────────────────
/// Helper: require the caller has `perm` on the given file id. /// Helper: require the caller has `perm` on the given file id.
/// Fail-closed if no engine was injected (stub/test path). /// Fail-closed if no engine was injected (stub/test path).
async fn require_file( async fn require_file(
@@ -81,7 +83,25 @@ impl FileRetrievalService {
.await .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. /// Try to transcode image content to WebP and return transcoded variant.
async fn try_transcode( async fn try_transcode(
@@ -229,12 +249,13 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(FileDto::from(file)) 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?; self.require_file(id, Permission::Read, caller_id).await?;
let file = self.file_read.get_file(id).await?; let file = self.file_read.get_file(id).await?;
Ok(FileDto::from(file)) Ok(FileDto::from(file))
} }
// FIXME no authorisation at all
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> { async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files) // Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
// NOTE: This method does NOT perform any authorization check. Callers // NOTE: This method does NOT perform any authorization check. Callers
@@ -256,17 +277,25 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(files.into_iter().map(FileDto::from).collect()) Ok(files.into_iter().map(FileDto::from).collect())
} }
async fn list_files_owned( async fn list_files_with_perms(
&self, &self,
folder_id: Option<&str>, folder_id: Option<&str>,
owner_id: Uuid, owner_id: Uuid,
) -> Result<Vec<FileDto>, DomainError> { ) -> 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?;
self.list_files(folder_id).await
} else {
// no folder id, get owners's files' root
let files = self let files = self
.file_read .file_read
.list_files_for_owner(folder_id, owner_id) .list_files_for_owner(folder_id, owner_id)
.await?; .await?;
Ok(files.into_iter().map(FileDto::from).collect()) Ok(files.into_iter().map(FileDto::from).collect())
} }
}
async fn get_file_stream( async fn get_file_stream(
&self, &self,
@@ -275,7 +304,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
self.file_read.get_file_stream(id).await self.file_read.get_file_stream(id).await
} }
async fn get_file_stream_owned( async fn get_file_stream_with_perms(
&self, &self,
id: &str, id: &str,
caller_id: Uuid, caller_id: Uuid,
@@ -297,7 +326,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
.await .await
} }
async fn get_file_optimized_owned( async fn get_file_optimized_with_perms(
&self, &self,
id: &str, id: &str,
caller_id: Uuid, caller_id: Uuid,
@@ -333,7 +362,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
self.file_read.get_file_range_stream(id, start, end).await 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, &self,
id: &str, id: &str,
caller_id: Uuid, caller_id: Uuid,
@@ -344,6 +373,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
self.file_read.get_file_range_stream(id, start, end).await self.file_read.get_file_range_stream(id, start, end).await
} }
// TODO: check: no permission check
async fn stream_files_in_subtree( async fn stream_files_in_subtree(
&self, &self,
folder_id: &str, folder_id: &str,
@@ -366,13 +396,24 @@ impl FileRetrievalUseCase for FileRetrievalService {
Ok(files.into_iter().map(FileDto::from).collect()) Ok(files.into_iter().map(FileDto::from).collect())
} }
async fn list_files_batch_for_owner( async fn list_files_batch_with_perms(
&self, &self,
folder_id: Option<&str>, folder_id: Option<&str>,
owner_id: Uuid, owner_id: Uuid,
offset: i64, offset: i64,
limit: i64, limit: i64,
) -> Result<Vec<FileDto>, DomainError> { ) -> 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 let files = self
.file_read .file_read
.list_files_batch_for_owner(folder_id, owner_id, offset, limit) .list_files_batch_for_owner(folder_id, owner_id, offset, limit)
+74 -14
View File
@@ -41,6 +41,14 @@ impl FolderService {
struct FolderServiceStub; struct FolderServiceStub;
impl FolderUseCase for 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( async fn create_folder_with_perms(
&self, &self,
_dto: CreateFolderDto, _dto: CreateFolderDto,
@@ -72,7 +80,7 @@ impl FolderService {
Ok(vec![]) Ok(vec![])
} }
async fn list_folders_for_owner( async fn list_folders_with_perms(
&self, &self,
_parent_id: Option<&str>, _parent_id: Option<&str>,
_owner_id: Uuid, _owner_id: Uuid,
@@ -98,7 +106,7 @@ impl FolderService {
) )
} }
async fn list_folders_for_owner_paginated( async fn list_folders_paginated_with_perms(
&self, &self,
_parent_id: Option<&str>, _parent_id: Option<&str>,
_owner_id: Uuid, _owner_id: Uuid,
@@ -157,6 +165,28 @@ impl FolderService {
} }
impl FolderUseCase for 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 /// Creates a new folder
async fn create_folder_with_perms( async fn create_folder_with_perms(
&self, &self,
@@ -271,6 +301,7 @@ impl FolderUseCase for FolderService {
.list_folders(parent_id) .list_folders(parent_id)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::warn!("errror while fetching folders {}", e);
DomainError::internal_error( DomainError::internal_error(
"FolderStorage", "FolderStorage",
format!("Failed to list folders in parent: {:?}: {}", parent_id, e), 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. /// Lists folders scoped to a specific owner.
/// Self-healing: if listing root folders and none exist, creates a home folder. /// 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, &self,
parent_id: Option<&str>, parent_id: Option<&str>,
owner_id: Uuid, caller_id: Uuid,
) -> Result<Vec<FolderDto>, DomainError> { ) -> Result<Vec<FolderDto>, 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;
} else {
// No parent defined grab user's homes
let folders = self let folders = self
.folder_storage .folder_storage
.list_folders_by_owner(parent_id, owner_id) .list_folders_by_owner(parent_id, caller_id)
.await .await
.map_err(|e| { .map_err(|e| {
DomainError::internal_error( DomainError::internal_error(
"FolderStorage", "FolderStorage",
format!( format!(
"Failed to list folders for owner '{}' in parent {:?}: {}", "Failed to list folders for owner '{}' in parent {:?}: {}",
owner_id, parent_id, e caller_id, parent_id, e
), ),
) )
})?; })?;
if folders.is_empty() {
// Self-healing: if listing root folders and none exist, create a home folder // Self-healing: if listing root folders and none exist, create a home folder
// This ensures the frontend always gets a valid userHomeFolderId // This ensures the frontend always gets a valid userHomeFolderId
if parent_id.is_none() && folders.is_empty() {
tracing::info!( tracing::info!(
"No root folders found for user {}, creating home folder automatically", "No root folders found for user {}, creating home folder automatically",
owner_id caller_id
); );
let owner_id_short = { let owner_id_short = {
let s = owner_id.to_string(); let s = caller_id.to_string();
s[..8.min(s.len())].to_string() s[..8.min(s.len())].to_string()
}; };
// TODO: what about i18n ?
let folder_name = format!("My Folder - {}", owner_id_short); let folder_name = format!("My Folder - {}", owner_id_short);
match self match self
.folder_storage .folder_storage
.create_home_folder(owner_id, folder_name.clone()) .create_home_folder(caller_id, folder_name.clone())
.await .await
{ {
Ok(home_folder) => { Ok(home_folder) => {
tracing::info!( tracing::info!(
"Created home folder '{}' for user {}", "Created home folder '{}' for user {}",
folder_name, folder_name,
owner_id caller_id
); );
return Ok(vec![FolderDto::from(home_folder)]); return Ok(vec![FolderDto::from(home_folder)]);
} }
Err(e) => { Err(e) => {
tracing::warn!("Failed to create home folder for user {}: {}", owner_id, 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. // 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 /// Lists folders with pagination
async fn list_folders_paginated( async fn list_folders_paginated(
@@ -373,7 +422,7 @@ impl FolderUseCase for FolderService {
} }
/// Lists folders with pagination, scoped to a specific owner. /// Lists folders with pagination, scoped to a specific owner.
async fn list_folders_for_owner_paginated( async fn list_folders_paginated_with_perms(
&self, &self,
parent_id: Option<&str>, parent_id: Option<&str>,
owner_id: Uuid, owner_id: Uuid,
@@ -382,6 +431,16 @@ impl FolderUseCase for FolderService {
{ {
let pagination = pagination.validate_and_adjust(); 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 let (folders, total_items) = self
.folder_storage .folder_storage
.list_folders_by_owner_paginated( .list_folders_by_owner_paginated(
@@ -413,6 +472,7 @@ impl FolderUseCase for FolderService {
Ok(response) Ok(response)
} }
}
/// Renames a folder after verifying the caller has `Update` permission. /// Renames a folder after verifying the caller has `Update` permission.
async fn rename_folder_with_perms( async fn rename_folder_with_perms(
@@ -387,7 +387,7 @@ use crate::common::stubs::StubFileRetrievalUseCase;
async fn stub_get_file_owned_returns_ok() { async fn stub_get_file_owned_returns_ok() {
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let stub = StubFileRetrievalUseCase; 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"); 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 user_id = Uuid::new_v4();
let stub = StubFileRetrievalUseCase; let stub = StubFileRetrievalUseCase;
let result = stub 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; .await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -179,9 +179,9 @@ impl ShareBrowseService {
) -> Result<FolderListingDto, DomainError> { ) -> Result<FolderListingDto, DomainError> {
let (folders_res, files_res) = tokio::join!( let (folders_res, files_res) = tokio::join!(
self.folder_service 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 self.file_retrieval
.list_files_owned(Some(parent_folder_id), owner_id), .list_files_with_perms(Some(parent_folder_id), owner_id),
); );
Ok(FolderListingDto { Ok(FolderListingDto {
folders: folders_res?, folders: folders_res?,
+35 -9
View File
@@ -6,18 +6,21 @@ use crate::application::dtos::display_helpers::{
category_for, icon_class_for, icon_special_class_for, category_for, icon_class_for, icon_special_class_for,
}; };
use crate::application::dtos::trash_dto::TrashedItemDto; 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::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository; 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_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository; use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository; use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::dedup_service::DedupService;
use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::file_content_cache::FileContentCache;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::infrastructure::services::thumbnail_service::ThumbnailService; 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 — invalidated when files are permanently deleted from trash.
content_cache: Option<Arc<FileContentCache>>, content_cache: Option<Arc<FileContentCache>>,
/// Authz engine
authz: Arc<PgAclEngine>,
/// Number of days items should be kept in trash before automatic cleanup /// Number of days items should be kept in trash before automatic cleanup
retention_days: u32, retention_days: u32,
} }
@@ -71,6 +77,7 @@ impl TrashService {
dedup_service: Arc<DedupService>, dedup_service: Arc<DedupService>,
thumbnail_service: Option<Arc<ThumbnailService>>, thumbnail_service: Option<Arc<ThumbnailService>>,
content_cache: Option<Arc<FileContentCache>>, content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
) -> Self { ) -> Self {
Self { Self {
trash_repository, trash_repository,
@@ -80,6 +87,7 @@ impl TrashService {
dedup_service, dedup_service,
thumbnail_service, thumbnail_service,
content_cache, content_cache,
authz,
retention_days, retention_days,
} }
} }
@@ -176,6 +184,7 @@ impl TrashUseCase for TrashService {
Ok(dtos) Ok(dtos)
} }
// TODO: change item_type into Resource enum
#[instrument(skip(self))] #[instrument(skip(self))]
async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> { async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> {
info!( info!(
@@ -209,10 +218,23 @@ impl TrashUseCase for TrashService {
"file" => { "file" => {
info!("Processing file to move to trash: {}", item_id); 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. // Get the file — ownership-verified at SQL level.
// Returns NotFound if the file does not exist OR belongs to // Returns NotFound if the file does not exist OR belongs to
// another user, preventing cross-user trash operations. // another user, preventing cross-user trash operations.
debug!("Getting file data (owner-scoped): {}", item_id); 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 let file = match self
.file_read_port .file_read_port
.get_file_for_owner(item_id, user_id) .get_file_for_owner(item_id, user_id)
@@ -236,6 +258,7 @@ impl TrashUseCase for TrashService {
debug!("Original file path: {}", original_path); debug!("Original file path: {}", original_path);
// Create the trash item // 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"); debug!("Creating TrashedItem object for the file");
let trashed_item = TrashedItem::new( let trashed_item = TrashedItem::new(
item_uuid, item_uuid,
@@ -286,6 +309,17 @@ impl TrashUseCase for TrashService {
Ok(()) Ok(())
} }
"folder" => { "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. // Get the folder and verify ownership.
// Returns NotFound if the folder does not exist or belongs // Returns NotFound if the folder does not exist or belongs
// to another user — prevents cross-user trash operations. // 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(); let original_path = folder.storage_path().to_string();
// Create the trash item // Create the trash item
// FIXME: item will be created with user_id that mat not be the owner_id
let trashed_item = TrashedItem::new( let trashed_item = TrashedItem::new(
item_uuid, item_uuid,
user_uuid, user_uuid,
+10 -5
View File
@@ -38,6 +38,7 @@ use crate::infrastructure::services::file_content_cache::{
use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService;
use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService; use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService;
use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::services::path_service::PathService;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService;
use crate::application::services::app_password_service::AppPasswordService; use crate::application::services::app_password_service::AppPasswordService;
@@ -350,7 +351,7 @@ impl AppServiceFactory {
repos: &RepositoryServices, repos: &RepositoryServices,
trash_service: Option<Arc<TrashService>>, trash_service: Option<Arc<TrashService>>,
db_pool: &Arc<PgPool>, db_pool: &Arc<PgPool>,
authz: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>, authz: &Arc<PgAclEngine>,
) -> ApplicationServices { ) -> ApplicationServices {
// Main services // Main services
let folder_service = Arc::new(FolderService::new( let folder_service = Arc::new(FolderService::new(
@@ -452,6 +453,7 @@ impl AppServiceFactory {
&self, &self,
repos: &RepositoryServices, repos: &RepositoryServices,
core: &CoreServices, core: &CoreServices,
authz: &Arc<PgAclEngine>,
) -> Option<Arc<TrashService>> { ) -> Option<Arc<TrashService>> {
if !self.config.features.enable_trash { if !self.config.features.enable_trash {
tracing::info!("Trash service is disabled in configuration"); tracing::info!("Trash service is disabled in configuration");
@@ -470,6 +472,7 @@ impl AppServiceFactory {
core.dedup_service.clone(), core.dedup_service.clone(),
Some(core.thumbnail_service.clone()), Some(core.thumbnail_service.clone()),
Some(core.file_content_cache.clone()), Some(core.file_content_cache.clone()),
authz.clone(),
)); ));
// Initialize cleanup service (bulk-deletes expired items in 2 SQL queries) // Initialize cleanup service (bulk-deletes expired items in 2 SQL queries)
@@ -609,10 +612,7 @@ impl AppServiceFactory {
// 2. Repository services (requires PgPool for all metadata) // 2. Repository services (requires PgPool for all metadata)
let repos = self.create_repository_services(&core, &pool); let repos = self.create_repository_services(&core, &pool);
// 3. Trash service (needed before application services) // 3a. Authorization engine — must exist before application services
let trash_service = self.create_trash_service(&repos, &core).await;
// 3b. Authorization engine — must exist before application services
// because services hold an Arc<PgAclEngine> for ReBAC checks. // because services hold an Arc<PgAclEngine> for ReBAC checks.
let authorization = build_authorization_engine( let authorization = build_authorization_engine(
pool.clone(), pool.clone(),
@@ -620,6 +620,11 @@ impl AppServiceFactory {
repos.file_read_repository.clone(), repos.file_read_repository.clone(),
); );
// 3b. Trash service (needed before application services)
let trash_service = self
.create_trash_service(&repos, &core, &authorization)
.await;
// 4. Application services (with trash + authz already wired) // 4. Application services (with trash + authz already wired)
let mut apps = self.create_application_services( let mut apps = self.create_application_services(
&core, &core,
+30 -7
View File
@@ -34,6 +34,7 @@ use crate::common::errors::DomainError;
use crate::domain::entities::file::File; use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder; use crate::domain::entities::folder::Folder;
use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::Permission;
use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale}; use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale};
use crate::domain::services::path_service::StoragePath; use crate::domain::services::path_service::StoragePath;
@@ -355,6 +356,15 @@ impl I18nService for StubI18nService {
pub struct StubFolderUseCase; pub struct StubFolderUseCase;
impl FolderUseCase for StubFolderUseCase { impl FolderUseCase for StubFolderUseCase {
async fn has_permission(
&self,
_caller_id: Uuid,
_permission: Permission,
_file_id: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn create_folder_with_perms( async fn create_folder_with_perms(
&self, &self,
_dto: CreateFolderDto, _dto: CreateFolderDto,
@@ -383,7 +393,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(Vec::new()) Ok(Vec::new())
} }
async fn list_folders_for_owner( async fn list_folders_with_perms(
&self, &self,
_parent_id: Option<&str>, _parent_id: Option<&str>,
_owner_id: Uuid, _owner_id: Uuid,
@@ -399,7 +409,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
} }
async fn list_folders_for_owner_paginated( async fn list_folders_paginated_with_perms(
&self, &self,
_parent_id: Option<&str>, _parent_id: Option<&str>,
_owner_id: Uuid, _owner_id: Uuid,
@@ -521,7 +531,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Vec::new()) Ok(Vec::new())
} }
async fn list_files_owned( async fn list_files_with_perms(
&self, &self,
_folder_id: Option<&str>, _folder_id: Option<&str>,
_owner_id: Uuid, _owner_id: Uuid,
@@ -537,7 +547,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::new(empty_stream)) Ok(Box::new(empty_stream))
} }
async fn get_file_stream_owned( async fn get_file_stream_with_perms(
&self, &self,
_id: &str, _id: &str,
_caller_id: Uuid, _caller_id: Uuid,
@@ -583,11 +593,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::pin(futures::stream::empty())) Ok(Box::pin(futures::stream::empty()))
} }
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> {
Ok(FileDto::default()) Ok(FileDto::default())
} }
async fn get_file_optimized_owned( async fn get_file_optimized_with_perms(
&self, &self,
_id: &str, _id: &str,
_caller_id: Uuid, _caller_id: Uuid,
@@ -604,7 +618,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
)) ))
} }
async fn get_file_range_stream_owned( async fn get_file_range_stream_with_perms(
&self, &self,
_id: &str, _id: &str,
_caller_id: Uuid, _caller_id: Uuid,
@@ -623,6 +637,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
pub struct StubFileManagementUseCase; pub struct StubFileManagementUseCase;
impl FileManagementUseCase for StubFileManagementUseCase { impl FileManagementUseCase for StubFileManagementUseCase {
async fn has_permission(
&self,
_caller_id: Uuid,
_permission: Permission,
_file_id: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn copy_file_with_perms( async fn copy_file_with_perms(
&self, &self,
_file_id: &str, _file_id: &str,
+37 -1
View File
@@ -5,6 +5,7 @@
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
//! maps them to / from `storage.access_grants` rows. //! maps them to / from `storage.access_grants` rows.
use std::fmt;
use uuid::Uuid; use uuid::Uuid;
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -60,6 +61,12 @@ impl Subject {
} }
} }
impl fmt::Display for Subject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", self.type_str(), self.id())
}
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Resource — what the permission is on // Resource — what the permission is on
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -68,6 +75,12 @@ impl Subject {
pub enum Resource { pub enum Resource {
Folder(Uuid), Folder(Uuid),
File(Uuid), File(Uuid),
// Reserved for future use:
// Calendar(Uuid),
// Reserved for future use:
// AddressBook(Uuid),
// Reserved for future use:
// Playlist(Uuid),
} }
impl Resource { impl Resource {
@@ -75,12 +88,20 @@ impl Resource {
match self { match self {
Resource::Folder(_) => "folder", Resource::Folder(_) => "folder",
Resource::File(_) => "file", Resource::File(_) => "file",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
} }
} }
pub fn id(&self) -> Uuid { pub fn id(&self) -> Uuid {
match self { match self {
Resource::Folder(id) | Resource::File(id) => *id, Resource::Folder(id)
| Resource::File(id)
//| Resource::Calendar(id)
//| Resource::AddressBook(id)
//| Resource::Playlist(id)
=> *id,
} }
} }
@@ -88,11 +109,20 @@ impl Resource {
match resource_type { match resource_type {
"folder" => Some(Resource::Folder(id)), "folder" => Some(Resource::Folder(id)),
"file" => Some(Resource::File(id)), "file" => Some(Resource::File(id)),
//"calendar" => Some(Resource::Calendar(id)),
//"adressbook" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
_ => None, _ => None,
} }
} }
} }
impl fmt::Display for Resource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", self.type_str(), self.id())
}
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Permission — what action is allowed // Permission — what action is allowed
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -151,6 +181,12 @@ impl Permission {
} }
} }
impl fmt::Display for Permission {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Grant — a row in storage.access_grants // Grant — a row in storage.access_grants
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -90,6 +90,8 @@ impl ChunkedUploadHandler {
/// "expires_at": 86400 /// "expires_at": 86400
/// } /// }
/// ``` /// ```
/// TODO: how is implemented security (owneship, permission ?)
/// current caveat: upload can start without know is path permits upload
pub(super) async fn create_upload_impl( pub(super) async fn create_upload_impl(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -264,6 +266,7 @@ impl ChunkedUploadHandler {
/// POST /api/uploads/:upload_id/complete - Finalize upload /// POST /api/uploads/:upload_id/complete - Finalize upload
/// ///
/// Assembles all chunks into the final file and creates the file record /// Assembles all chunks into the final file and creates the file record
// TODO: how is implemented security (owneship, permission ?)
pub(super) async fn complete_upload_impl( pub(super) async fn complete_upload_impl(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
auth_user: AuthUser, auth_user: AuthUser,
+54 -28
View File
@@ -11,17 +11,17 @@ use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::OptimizedFileContent;
use crate::application::ports::file_ports::{ use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
}; };
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase};
use crate::common::di::AppState; use crate::common::di::AppState;
use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::infrastructure::services::audio_metadata_service::AudioMetadataService;
use crate::interfaces::errors::AppError; use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission};
use std::sync::Arc; use std::sync::Arc;
/** /**
@@ -85,6 +85,7 @@ impl FileHandler {
tracing::debug!("📤 Processing streaming file upload (hash-on-write)"); tracing::debug!("📤 Processing streaming file upload (hash-on-write)");
// caveat: if folder_id field is given after check can fails
while let Some(field) = multipart.next_field().await.unwrap_or(None) { while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
@@ -115,24 +116,24 @@ impl FileHandler {
.unwrap_or("application/octet-stream") .unwrap_or("application/octet-stream")
.to_string(); .to_string();
// ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ── // ── Fail-fast pre-check: verify the caller can Create inside
if let Some(ref fid) = folder_id { // the target folder BEFORE spooling the multipart body to disk.
use crate::application::ports::folder_ports::FolderUseCase; // The upload service re-checks at write time — this is a
let folder_service = &state.applications.folder_service; // UX/resource optimization, not the security boundary.
if folder_service if let Some(ref fid) = folder_id
.get_folder_with_perms(fid, auth_user.id) && let Err(err) = state
.applications
.folder_service_concrete
.has_permission(auth_user.id, Permission::Create, fid)
.await .await
.is_err()
{ {
tracing::warn!( tracing::warn!(
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user", "⛔ UPLOAD REJECTED: user='{}' folder='{}' err='{}'",
auth_user.username, auth_user.username,
fid, fid,
err
); );
return Err(Self::domain_error_response( return Err(Self::domain_error_response(err));
crate::common::errors::DomainError::not_found("Folder", fid),
));
}
} }
// ── Early quota check (before spooling to disk) ────── // ── Early quota check (before spooling to disk) ──────
@@ -328,6 +329,16 @@ impl FileHandler {
) -> impl IntoResponse { ) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize; use crate::application::ports::thumbnail_ports::ThumbnailSize;
// check first that user can access this resource
if let Err(err) = state
.applications
.file_management_service
.has_permission(auth_user.id, Permission::Read, &id)
.await
{
return AppError::from(err).into_response();
}
let thumbnail_service = &state.core.thumbnail_service; let thumbnail_service = &state.core.thumbnail_service;
let thumb_size = match size.as_str() { let thumb_size = match size.as_str() {
@@ -385,7 +396,7 @@ impl FileHandler {
let file_retrieval_service = &state.applications.file_retrieval_service; let file_retrieval_service = &state.applications.file_retrieval_service;
let file = match file_retrieval_service let file = match file_retrieval_service
.get_file_owned(&id, auth_user.id) .get_file_with_perms(&id, auth_user.id)
.await .await
{ {
Ok(f) => f, Ok(f) => f,
@@ -478,6 +489,16 @@ impl FileHandler {
) -> impl IntoResponse { ) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize; use crate::application::ports::thumbnail_ports::ThumbnailSize;
// check first that user can access this resource
if let Err(err) = state
.applications
.file_management_service
.has_permission(auth_user.id, Permission::Update, &id)
.await
{
return AppError::from(err).into_response();
}
let thumbnail_service = &state.core.thumbnail_service; let thumbnail_service = &state.core.thumbnail_service;
// Validate size // Validate size
@@ -508,7 +529,7 @@ impl FileHandler {
// Validate file ownership // Validate file ownership
let file_retrieval_service = &state.applications.file_retrieval_service; let file_retrieval_service = &state.applications.file_retrieval_service;
if let Err(err) = file_retrieval_service if let Err(err) = file_retrieval_service
.get_file_owned(&id, auth_user.id) .get_file_with_perms(&id, auth_user.id)
.await .await
{ {
return AppError::from(err).into_response(); return AppError::from(err).into_response();
@@ -545,7 +566,7 @@ impl FileHandler {
let retrieval = &state.applications.file_retrieval_service; let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata (ownership-scoped) ──────────────────────── // ── Get file metadata (ownership-scoped) ────────────────────────
let file_dto = match retrieval.get_file_owned(&id, auth_user.id).await { let file_dto = match retrieval.get_file_with_perms(&id, auth_user.id).await {
Ok(f) => f, Ok(f) => f,
Err(err) => { Err(err) => {
return AppError::from(err).into_response(); return AppError::from(err).into_response();
@@ -603,7 +624,7 @@ impl FileHandler {
Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params); Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
match retrieval match retrieval
.get_file_range_stream_owned(&id, auth_user.id, start, Some(end + 1)) .get_file_range_stream_with_perms(&id, auth_user.id, start, Some(end + 1))
.await .await
{ {
Ok(stream) => { Ok(stream) => {
@@ -713,7 +734,10 @@ impl FileHandler {
tracing::info!("API: Listing files with folder_id: {:?}", folder_id); tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
let retrieval = &state.applications.file_retrieval_service; let retrieval = &state.applications.file_retrieval_service;
match retrieval.list_files_owned(folder_id, auth_user.id).await { match retrieval
.list_files_with_perms(folder_id, auth_user.id)
.await
{
Ok(files) => { Ok(files) => {
// Compute lightweight ETag from max modified_at + count // Compute lightweight ETag from max modified_at + count
let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0); let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0);
@@ -751,6 +775,7 @@ impl FileHandler {
/// Delegates to [`Self::upload_file_inner`] and, on success, spawns /// Delegates to [`Self::upload_file_inner`] and, on success, spawns
/// a background task to generate all thumbnail sizes before serialising /// a background task to generate all thumbnail sizes before serialising
/// the `FileDto` once. /// the `FileDto` once.
/// TODO: should move thumbnail generation to a generic hook ? (onfileUploaded, other services will beneficiate it)
pub(super) async fn upload_file_with_thumbnails_impl( pub(super) async fn upload_file_with_thumbnails_impl(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
@@ -797,6 +822,7 @@ impl FileHandler {
}); });
} }
// TODO: same remark: a hook to handle easily audio service
// Extract audio metadata for supported audio files in background. // Extract audio metadata for supported audio files in background.
if let Some(ref audio_service) = state.applications.audio_metadata_service if let Some(ref audio_service) = state.applications.audio_metadata_service
&& AudioMetadataService::is_audio_file(&file.mime_type) && AudioMetadataService::is_audio_file(&file.mime_type)
@@ -825,15 +851,14 @@ impl FileHandler {
auth_user: AuthUser, auth_user: AuthUser,
Path(file_id): Path<String>, Path(file_id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Verify ownership // check first that user can access this resource
let file_read = &state.repositories.file_read_repository; if let Err(err) = state
if let Err(e) = file_read.verify_file_owner(&file_id, auth_user.id).await { .applications
let msg = e.to_string(); .file_management_service
return ( .has_permission(auth_user.id, Permission::Read, &file_id)
StatusCode::NOT_FOUND, .await
Json(serde_json::json!({ "error": msg })), {
) return AppError::from(err).into_response();
.into_response();
} }
let metadata_repo = &state.repositories.file_metadata_repository; let metadata_repo = &state.repositories.file_metadata_repository;
@@ -927,6 +952,7 @@ impl FileHandler {
} }
/// Moves a file to a different folder (ownership-verified) /// Moves a file to a different folder (ownership-verified)
/// TODO: dead function ?
pub async fn move_file( pub async fn move_file(
State(state): State<GlobalState>, State(state): State<GlobalState>,
auth_user: AuthUser, auth_user: AuthUser,
+12 -37
View File
@@ -51,7 +51,7 @@ impl FolderHandler {
"create_folder: parent_id is None for user '{}', resolving home folder", "create_folder: parent_id is None for user '{}', resolving home folder",
auth_user.username auth_user.username
); );
match service.list_folders_for_owner(None, auth_user.id).await { match service.list_folders_with_perms(None, auth_user.id).await {
Ok(folders) => { Ok(folders) => {
if let Some(home) = folders.first() { if let Some(home) = folders.first() {
tracing::info!( tracing::info!(
@@ -89,22 +89,8 @@ impl FolderHandler {
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match service.get_folder(&id).await { match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
// Access check: folder must belong to the requesting user
if let Some(ref owner) = folder.owner_id
&& owner != &auth_user.id.to_string()
{
tracing::warn!(
"get_folder: user '{}' attempted to access folder '{}' owned by '{}'",
auth_user.id,
id,
owner
);
return AppError::not_found("Folder not found").into_response();
}
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(), Err(err) => AppError::from(err).into_response(),
} }
} }
@@ -146,7 +132,7 @@ impl FolderHandler {
pagination: Query<PaginationRequestDto>, pagination: Query<PaginationRequestDto>,
) -> axum::response::Response { ) -> axum::response::Response {
match service match service
.list_folders_for_owner_paginated(Some(&id), auth_user.id, &pagination) .list_folders_paginated_with_perms(Some(&id), auth_user.id, &pagination)
.await .await
{ {
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
@@ -163,7 +149,7 @@ impl FolderHandler {
auth_user: &AuthUser, auth_user: &AuthUser,
) -> axum::response::Response { ) -> axum::response::Response {
match service match service
.list_folders_for_owner(parent_id, auth_user.id) .list_folders_with_perms(parent_id, auth_user.id)
.await .await
{ {
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
@@ -206,8 +192,8 @@ impl FolderHandler {
// Run both queries concurrently — no sequential wait. // Run both queries concurrently — no sequential wait.
let (folders_result, files_result) = tokio::join!( let (folders_result, files_result) = tokio::join!(
folder_service.list_folders_for_owner(Some(&id), auth_user.id), folder_service.list_folders_with_perms(Some(&id), auth_user.id),
file_service.list_files_owned(Some(&id), auth_user.id) file_service.list_files_with_perms(Some(&id), auth_user.id)
); );
match (folders_result, files_result) { match (folders_result, files_result) {
@@ -226,7 +212,6 @@ impl FolderHandler {
.unwrap() .unwrap()
.into_response(); .into_response();
} }
let listing = FolderListingDto { folders, files }; let listing = FolderListingDto { folders, files };
let mut resp = (StatusCode::OK, Json(listing)).into_response(); let mut resp = (StatusCode::OK, Json(listing)).into_response();
resp.headers_mut() resp.headers_mut()
@@ -286,6 +271,7 @@ impl FolderHandler {
) -> impl IntoResponse { ) -> impl IntoResponse {
let user_id = auth_user.id; let user_id = auth_user.id;
// Check if trash service is available // Check if trash service is available
// FIXME: permissions !!
if let Some(trash_service) = &state.trash_service { if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id); tracing::info!("Moving folder to trash: {}", id);
@@ -328,22 +314,11 @@ impl FolderHandler {
// Get folder information and verify ownership // Get folder information and verify ownership
let folder_service = &state.applications.folder_service; let folder_service = &state.applications.folder_service;
match folder_service.get_folder(&id).await { match folder_service
.get_folder_with_perms(&id, auth_user.id)
.await
{
Ok(folder) => { Ok(folder) => {
// Access check: folder must belong to the requesting user
if folder.owner_id.as_deref() != Some(&auth_user.id.to_string()) {
tracing::warn!(
"download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'",
auth_user.id,
id,
folder.owner_id
);
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Folder not found" })),
)
.into_response();
}
tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id);
// Use ZIP service from DI container // Use ZIP service from DI container
@@ -561,6 +561,7 @@ fn share_browse_error_response(err: crate::common::errors::DomainError) -> Respo
AppError::from(err).into_response() AppError::from(err).into_response()
} }
// TODO: remove this and use the classic /api/files & /api/folders get, but with the token as session ?
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/s/{token}/contents", path = "/api/s/{token}/contents",
@@ -189,7 +189,7 @@ async fn handle_webdav_methods(
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> { async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> {
let folder_service = &state.applications.folder_service; let folder_service = &state.applications.folder_service;
let home_folders = folder_service let home_folders = folder_service
.list_folders_for_owner(None, user_id) .list_folders_with_perms(None, user_id)
.await .await
.ok()?; .ok()?;
let home = home_folders.first()?; let home = home_folders.first()?;
@@ -514,7 +514,7 @@ async fn build_streaming_propfind_response(
page_size: pagination.page_size, page_size: pagination.page_size,
}; };
let result = folder_service let result = folder_service
.list_folders_for_owner_paginated(fid_ref, user_id, &pag) .list_folders_paginated_with_perms(fid_ref, user_id, &pag)
.await .await
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
@@ -544,7 +544,7 @@ async fn build_streaming_propfind_response(
let mut offset: i64 = 0; let mut offset: i64 = 0;
loop { loop {
let batch: Vec<FileDto> = file_retrieval_service let batch: Vec<FileDto> = file_retrieval_service
.list_files_batch_for_owner(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE) .list_files_batch_with_perms(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE)
.await .await
.map_err(|e| std::io::Error::other(e.to_string()))?; .map_err(|e| std::io::Error::other(e.to_string()))?;
+1 -1
View File
@@ -401,7 +401,7 @@ async fn authorize_wopi_access<S: FileRetrievalUseCase>(
requested_action: &str, requested_action: &str,
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> { ) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
let file = file_retrieval let file = file_retrieval
.get_file_owned(file_id, caller_id) .get_file_with_perms(file_id, caller_id)
.await .await
.map_err(|_| StatusCode::NOT_FOUND)?; .map_err(|_| StatusCode::NOT_FOUND)?;
// Owner verified — grant write unless explicitly requesting view-only. // Owner verified — grant write unless explicitly requesting view-only.
+1
View File
@@ -17,6 +17,7 @@ OXICLOUD_WOPI_ENABLED=false
OXICLOUD_OIDC_ENABLED=false OXICLOUD_OIDC_ENABLED=false
RUST_LOG=warn RUST_LOG=warn
#RUST_LOG=debug #RUST_LOG=debug
#RUST_LOG=info
# grow up limits for tests # grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120 OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
+6
View File
@@ -1,6 +1,9 @@
#!/bin/bash #!/bin/bash
if [ -z "$base_url" ]
then
source test.env source test.env
fi
err() { err() {
echo "$*" >&2 echo "$*" >&2
@@ -32,7 +35,10 @@ oxicloud_setup() {
# returns TOKEN variable # returns TOKEN variable
oxicloud_login() { oxicloud_login() {
if [[ ( $# -eq 0 ) || ( "$1" != "no-create" ) ]]
then
oxicloud_setup oxicloud_setup
fi
LOGIN_DATA='{"username":"'$username'","password":"'$password'"}' LOGIN_DATA='{"username":"'$username'","password":"'$password'"}'