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,
) -> 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()))
}
}
+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_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,
+10 -2
View File
@@ -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,
+7 -3
View File
@@ -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)
+115 -55
View File
@@ -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?,
+35 -9
View File
@@ -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,
+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::nextcloud_chunked_upload_service::NextcloudChunkedUploadService;
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::application::services::app_password_service::AppPasswordService;
@@ -350,7 +351,7 @@ impl AppServiceFactory {
repos: &RepositoryServices,
trash_service: Option<Arc<TrashService>>,
db_pool: &Arc<PgPool>,
authz: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
authz: &Arc<PgAclEngine>,
) -> ApplicationServices {
// Main services
let folder_service = Arc::new(FolderService::new(
@@ -452,6 +453,7 @@ impl AppServiceFactory {
&self,
repos: &RepositoryServices,
core: &CoreServices,
authz: &Arc<PgAclEngine>,
) -> Option<Arc<TrashService>> {
if !self.config.features.enable_trash {
tracing::info!("Trash service is disabled in configuration");
@@ -470,6 +472,7 @@ impl AppServiceFactory {
core.dedup_service.clone(),
Some(core.thumbnail_service.clone()),
Some(core.file_content_cache.clone()),
authz.clone(),
));
// 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)
let repos = self.create_repository_services(&core, &pool);
// 3. Trash service (needed before application services)
let trash_service = self.create_trash_service(&repos, &core).await;
// 3b. Authorization engine — must exist before application services
// 3a. Authorization engine — must exist before application services
// because services hold an Arc<PgAclEngine> for ReBAC checks.
let authorization = build_authorization_engine(
pool.clone(),
@@ -620,6 +620,11 @@ impl AppServiceFactory {
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)
let mut apps = self.create_application_services(
&core,
+30 -7
View File
@@ -34,6 +34,7 @@ use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder;
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::path_service::StoragePath;
@@ -355,6 +356,15 @@ impl I18nService for StubI18nService {
pub struct 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(
&self,
_dto: CreateFolderDto,
@@ -383,7 +393,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(Vec::new())
}
async fn list_folders_for_owner(
async fn list_folders_with_perms(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
@@ -399,7 +409,7 @@ impl FolderUseCase for StubFolderUseCase {
Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0))
}
async fn list_folders_for_owner_paginated(
async fn list_folders_paginated_with_perms(
&self,
_parent_id: Option<&str>,
_owner_id: Uuid,
@@ -521,7 +531,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Vec::new())
}
async fn list_files_owned(
async fn list_files_with_perms(
&self,
_folder_id: Option<&str>,
_owner_id: Uuid,
@@ -537,7 +547,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::new(empty_stream))
}
async fn get_file_stream_owned(
async fn get_file_stream_with_perms(
&self,
_id: &str,
_caller_id: Uuid,
@@ -583,11 +593,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
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())
}
async fn get_file_optimized_owned(
async fn get_file_optimized_with_perms(
&self,
_id: &str,
_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,
_id: &str,
_caller_id: Uuid,
@@ -623,6 +637,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
pub struct 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(
&self,
_file_id: &str,
+37 -1
View File
@@ -5,6 +5,7 @@
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
//! maps them to / from `storage.access_grants` rows.
use std::fmt;
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
// ════════════════════════════════════════════════════════════════════════════
@@ -68,6 +75,12 @@ impl Subject {
pub enum Resource {
Folder(Uuid),
File(Uuid),
// Reserved for future use:
// Calendar(Uuid),
// Reserved for future use:
// AddressBook(Uuid),
// Reserved for future use:
// Playlist(Uuid),
}
impl Resource {
@@ -75,12 +88,20 @@ impl Resource {
match self {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
}
}
pub fn id(&self) -> Uuid {
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 {
"folder" => Some(Resource::Folder(id)),
"file" => Some(Resource::File(id)),
//"calendar" => Some(Resource::Calendar(id)),
//"adressbook" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
_ => 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
// ════════════════════════════════════════════════════════════════════════════
@@ -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
// ════════════════════════════════════════════════════════════════════════════
@@ -90,6 +90,8 @@ impl ChunkedUploadHandler {
/// "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(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
@@ -264,6 +266,7 @@ impl ChunkedUploadHandler {
/// POST /api/uploads/:upload_id/complete - Finalize upload
///
/// 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(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
+59 -33
View File
@@ -11,17 +11,17 @@ use serde::Deserialize;
use std::collections::HashMap;
use utoipa::ToSchema;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::OptimizedFileContent;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase};
use crate::common::di::AppState;
use crate::infrastructure::services::audio_metadata_service::AudioMetadataService;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission};
use std::sync::Arc;
/**
@@ -85,6 +85,7 @@ impl FileHandler {
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) {
let name = field.name().unwrap_or("").to_string();
@@ -115,24 +116,24 @@ impl FileHandler {
.unwrap_or("application/octet-stream")
.to_string();
// ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ──
if let Some(ref fid) = folder_id {
use crate::application::ports::folder_ports::FolderUseCase;
let folder_service = &state.applications.folder_service;
if folder_service
.get_folder_with_perms(fid, auth_user.id)
// ── Fail-fast pre-check: verify the caller can Create inside
// the target folder BEFORE spooling the multipart body to disk.
// The upload service re-checks at write time — this is a
// UX/resource optimization, not the security boundary.
if let Some(ref fid) = folder_id
&& let Err(err) = state
.applications
.folder_service_concrete
.has_permission(auth_user.id, Permission::Create, fid)
.await
.is_err()
{
tracing::warn!(
"⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user",
auth_user.username,
fid,
);
return Err(Self::domain_error_response(
crate::common::errors::DomainError::not_found("Folder", fid),
));
}
{
tracing::warn!(
"⛔ UPLOAD REJECTED: user='{}' folder='{}' err='{}'",
auth_user.username,
fid,
err
);
return Err(Self::domain_error_response(err));
}
// ── Early quota check (before spooling to disk) ──────
@@ -328,6 +329,16 @@ impl FileHandler {
) -> impl IntoResponse {
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 thumb_size = match size.as_str() {
@@ -385,7 +396,7 @@ impl FileHandler {
let file_retrieval_service = &state.applications.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
{
Ok(f) => f,
@@ -478,6 +489,16 @@ impl FileHandler {
) -> impl IntoResponse {
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;
// Validate size
@@ -508,7 +529,7 @@ impl FileHandler {
// Validate file ownership
let file_retrieval_service = &state.applications.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
{
return AppError::from(err).into_response();
@@ -545,7 +566,7 @@ impl FileHandler {
let retrieval = &state.applications.file_retrieval_service;
// ── 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,
Err(err) => {
return AppError::from(err).into_response();
@@ -603,7 +624,7 @@ impl FileHandler {
Self::content_disposition(&file_dto.name, &file_dto.mime_type, &params);
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
{
Ok(stream) => {
@@ -713,7 +734,10 @@ impl FileHandler {
tracing::info!("API: Listing files with folder_id: {:?}", folder_id);
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) => {
// Compute lightweight ETag from max modified_at + count
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
/// a background task to generate all thumbnail sizes before serialising
/// 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(
State(state): State<GlobalState>,
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.
if let Some(ref audio_service) = state.applications.audio_metadata_service
&& AudioMetadataService::is_audio_file(&file.mime_type)
@@ -825,15 +851,14 @@ impl FileHandler {
auth_user: AuthUser,
Path(file_id): Path<String>,
) -> impl IntoResponse {
// Verify ownership
let file_read = &state.repositories.file_read_repository;
if let Err(e) = file_read.verify_file_owner(&file_id, auth_user.id).await {
let msg = e.to_string();
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": msg })),
)
.into_response();
// check first that user can access this resource
if let Err(err) = state
.applications
.file_management_service
.has_permission(auth_user.id, Permission::Read, &file_id)
.await
{
return AppError::from(err).into_response();
}
let metadata_repo = &state.repositories.file_metadata_repository;
@@ -927,6 +952,7 @@ impl FileHandler {
}
/// Moves a file to a different folder (ownership-verified)
/// TODO: dead function ?
pub async fn move_file(
State(state): State<GlobalState>,
auth_user: AuthUser,
+12 -37
View File
@@ -51,7 +51,7 @@ impl FolderHandler {
"create_folder: parent_id is None for user '{}', resolving home folder",
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) => {
if let Some(home) = folders.first() {
tracing::info!(
@@ -89,22 +89,8 @@ impl FolderHandler {
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
match service.get_folder(&id).await {
Ok(folder) => {
// 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()
}
match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
@@ -146,7 +132,7 @@ impl FolderHandler {
pagination: Query<PaginationRequestDto>,
) -> axum::response::Response {
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
{
Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(),
@@ -163,7 +149,7 @@ impl FolderHandler {
auth_user: &AuthUser,
) -> axum::response::Response {
match service
.list_folders_for_owner(parent_id, auth_user.id)
.list_folders_with_perms(parent_id, auth_user.id)
.await
{
Ok(folders) => (StatusCode::OK, Json(folders)).into_response(),
@@ -206,8 +192,8 @@ impl FolderHandler {
// Run both queries concurrently — no sequential wait.
let (folders_result, files_result) = tokio::join!(
folder_service.list_folders_for_owner(Some(&id), auth_user.id),
file_service.list_files_owned(Some(&id), auth_user.id)
folder_service.list_folders_with_perms(Some(&id), auth_user.id),
file_service.list_files_with_perms(Some(&id), auth_user.id)
);
match (folders_result, files_result) {
@@ -226,7 +212,6 @@ impl FolderHandler {
.unwrap()
.into_response();
}
let listing = FolderListingDto { folders, files };
let mut resp = (StatusCode::OK, Json(listing)).into_response();
resp.headers_mut()
@@ -286,6 +271,7 @@ impl FolderHandler {
) -> impl IntoResponse {
let user_id = auth_user.id;
// Check if trash service is available
// FIXME: permissions !!
if let Some(trash_service) = &state.trash_service {
tracing::info!("Moving folder to trash: {}", id);
@@ -328,22 +314,11 @@ impl FolderHandler {
// Get folder information and verify ownership
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) => {
// 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);
// 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()
}
// TODO: remove this and use the classic /api/files & /api/folders get, but with the token as session ?
#[utoipa::path(
get,
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> {
let folder_service = &state.applications.folder_service;
let home_folders = folder_service
.list_folders_for_owner(None, user_id)
.list_folders_with_perms(None, user_id)
.await
.ok()?;
let home = home_folders.first()?;
@@ -514,7 +514,7 @@ async fn build_streaming_propfind_response(
page_size: pagination.page_size,
};
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
.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;
loop {
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
.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,
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
let file = file_retrieval
.get_file_owned(file_id, caller_id)
.get_file_with_perms(file_id, caller_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Owner verified — grant write unless explicitly requesting view-only.
+1
View File
@@ -17,6 +17,7 @@ OXICLOUD_WOPI_ENABLED=false
OXICLOUD_OIDC_ENABLED=false
RUST_LOG=warn
#RUST_LOG=debug
#RUST_LOG=info
# grow up limits for tests
OXICLOUD_RATE_LIMIT_REFRESH_MAX=120
+8 -2
View File
@@ -1,6 +1,9 @@
#!/bin/bash
source test.env
if [ -z "$base_url" ]
then
source test.env
fi
err() {
echo "$*" >&2
@@ -32,7 +35,10 @@ oxicloud_setup() {
# returns TOKEN variable
oxicloud_login() {
oxicloud_setup
if [[ ( $# -eq 0 ) || ( "$1" != "no-create" ) ]]
then
oxicloud_setup
fi
LOGIN_DATA='{"username":"'$username'","password":"'$password'"}'