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,