feat(rebac): first pass
This commit is contained in:
@@ -89,9 +89,18 @@ mod tests {
|
||||
let file_read_repo = Arc::new(FileBlobReadRepository::new_stub());
|
||||
let file_write_repo = Arc::new(FileBlobWriteRepository::new_stub());
|
||||
|
||||
let file_retrieval = Arc::new(FileRetrievalService::new(file_read_repo));
|
||||
let file_management = Arc::new(FileManagementService::new(file_write_repo));
|
||||
let folder_service = Arc::new(FolderService::new(folder_repo));
|
||||
let authz =
|
||||
Arc::new(crate::infrastructure::services::pg_acl_engine::PgAclEngine::new_stub());
|
||||
let file_retrieval = Arc::new(FileRetrievalService::new(file_read_repo.clone()));
|
||||
let file_management = Arc::new(FileManagementService::with_trash(
|
||||
file_write_repo,
|
||||
None,
|
||||
Some(file_read_repo),
|
||||
None,
|
||||
None,
|
||||
authz.clone(),
|
||||
));
|
||||
let folder_service = Arc::new(FolderService::new(folder_repo, authz));
|
||||
|
||||
let _batch_service = BatchOperationService::new(
|
||||
file_retrieval,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileDeletedHook;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort};
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::services::trash_service::TrashService;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::path_service::validate_storage_name;
|
||||
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::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -23,41 +26,32 @@ use uuid::Uuid;
|
||||
/// touches ref_count directly.
|
||||
pub struct FileManagementService {
|
||||
file_repository: Arc<FileBlobWriteRepository>,
|
||||
file_read: Option<Arc<FileBlobReadRepository>>,
|
||||
folder_repo: Option<Arc<FolderDbRepository>>,
|
||||
trash_service: Option<Arc<TrashService>>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
/// Hooks fired after a file is permanently deleted.
|
||||
file_deleted_hooks: Vec<Arc<dyn FileDeletedHook>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
/// Creates a new FileManagementService.
|
||||
pub fn new(file_repository: Arc<FileBlobWriteRepository>) -> Self {
|
||||
Self {
|
||||
file_repository,
|
||||
file_read: None,
|
||||
folder_repo: None,
|
||||
trash_service: None,
|
||||
content_cache: None,
|
||||
file_deleted_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a FileManagementService with a trash service, read repo, and folder repo for ownership checks.
|
||||
/// Creates a FileManagementService with a trash service, content cache
|
||||
/// and the ReBAC authorization engine. File/folder owner lookups (used
|
||||
/// for owner short-circuit inside the engine) are now the engine's
|
||||
/// responsibility — this service no longer holds direct repo references
|
||||
/// for ownership.
|
||||
pub fn with_trash(
|
||||
file_repository: Arc<FileBlobWriteRepository>,
|
||||
trash_service: Option<Arc<TrashService>>,
|
||||
file_read: Option<Arc<FileBlobReadRepository>>,
|
||||
folder_repo: Option<Arc<FolderDbRepository>>,
|
||||
_file_read: Option<Arc<FileBlobReadRepository>>,
|
||||
_folder_repo: Option<Arc<FolderDbRepository>>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_repository,
|
||||
file_read,
|
||||
folder_repo,
|
||||
trash_service,
|
||||
content_cache,
|
||||
authz,
|
||||
file_deleted_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -68,40 +62,35 @@ impl FileManagementService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Verifies ownership via the read repository.
|
||||
async fn verify_owner(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
if let Some(read) = &self.file_read {
|
||||
read.verify_file_owner(file_id, caller_id).await
|
||||
} else {
|
||||
// Fallback: no read repo injected — deny by default (fail-closed)
|
||||
Err(DomainError::internal_error(
|
||||
"FileManagement",
|
||||
"Ownership verification unavailable",
|
||||
))
|
||||
}
|
||||
/// Engine check for a file resource. Parses the id into a `Uuid` and
|
||||
/// requires the specified permission.
|
||||
async fn require_file_perm(
|
||||
&self,
|
||||
file_id: &str,
|
||||
perm: Permission,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), perm, Resource::File(uuid))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Verifies that the target folder is owned by the caller.
|
||||
///
|
||||
/// `None` means the target is the user's root namespace
|
||||
/// (`storage.files.folder_id IS NULL`) — implicitly owned by the caller, so
|
||||
/// the check is skipped. Fails closed if `folder_repo` was not injected.
|
||||
async fn verify_target_folder_owner(
|
||||
/// 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 {
|
||||
// TODO: File creation to root is currently allowed, check is this policy is relevant
|
||||
return Ok(());
|
||||
};
|
||||
let Some(folder_repo) = &self.folder_repo else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileManagement",
|
||||
"Folder ownership verification unavailable",
|
||||
));
|
||||
};
|
||||
folder_repo.verify_owner(target, caller_id).await
|
||||
let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?;
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), perm, Resource::Folder(uuid))
|
||||
.await
|
||||
}
|
||||
|
||||
//impl FileManagementPrivateUseCase for FileManagementService {
|
||||
@@ -242,10 +231,10 @@ impl FileManagementUseCase for FileManagementService {
|
||||
caller_id: Uuid,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// Verify file ownership first
|
||||
self.verify_owner(file_id, caller_id).await?;
|
||||
// Verify target folder ownership (prevents file from "disappearing")
|
||||
self.verify_target_folder_owner(folder_id.as_deref(), caller_id)
|
||||
// Move = Update on the file + Create on the target folder (if any).
|
||||
self.require_file_perm(file_id, Permission::Update, caller_id)
|
||||
.await?;
|
||||
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
self.move_file(file_id, folder_id).await
|
||||
}
|
||||
@@ -256,8 +245,10 @@ impl FileManagementUseCase for FileManagementService {
|
||||
caller_id: Uuid,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
self.verify_owner(file_id, caller_id).await?;
|
||||
self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id)
|
||||
// Copy = Read on the source file + Create on the target folder.
|
||||
self.require_file_perm(file_id, Permission::Read, caller_id)
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
self.copy_file(file_id, target_folder_id).await
|
||||
}
|
||||
@@ -268,12 +259,14 @@ impl FileManagementUseCase for FileManagementService {
|
||||
caller_id: Uuid,
|
||||
new_name: &str,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
self.verify_owner(file_id, caller_id).await?;
|
||||
self.require_file_perm(file_id, Permission::Update, caller_id)
|
||||
.await?;
|
||||
self.rename_file(file_id, new_name).await
|
||||
}
|
||||
|
||||
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
self.verify_owner(id, caller_id).await?;
|
||||
self.require_file_perm(id, Permission::Delete, caller_id)
|
||||
.await?;
|
||||
self.delete_file(id).await
|
||||
}
|
||||
|
||||
@@ -288,7 +281,8 @@ impl FileManagementUseCase for FileManagementService {
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
self.verify_owner(id, caller_id).await?;
|
||||
self.require_file_perm(id, Permission::Delete, caller_id)
|
||||
.await?;
|
||||
// Step 1: Try trash (soft delete — file row stays, blob stays referenced)
|
||||
if let Some(trash) = &self.trash_service {
|
||||
info!("Moving file to trash: {}", id);
|
||||
@@ -328,11 +322,10 @@ impl FileManagementUseCase for FileManagementService {
|
||||
target_parent_id: Option<String>,
|
||||
dest_name: Option<String>,
|
||||
) -> Result<CopyFolderTreeResult, DomainError> {
|
||||
// Source ownership: source_folder_id is required (not optional), but reuse the
|
||||
// wrapper which also enforces the fail-closed semantics if folder_repo is absent.
|
||||
self.verify_target_folder_owner(Some(source_folder_id), caller_id)
|
||||
// copy_folder_tree = Read on the source folder + Create on the target parent.
|
||||
self.require_target_folder_perm(Some(source_folder_id), Permission::Read, caller_id)
|
||||
.await?;
|
||||
self.verify_target_folder_owner(target_parent_id.as_deref(), caller_id)
|
||||
self.require_target_folder_perm(target_parent_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
|
||||
.await
|
||||
|
||||
@@ -4,14 +4,17 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::image_transcode_service::{
|
||||
ImageTranscodeService, OutputFormat,
|
||||
};
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use tracing::{debug, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -29,31 +32,55 @@ pub struct FileRetrievalService {
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
transcode: Option<Arc<ImageTranscodeService>>,
|
||||
authz: Option<Arc<PgAclEngine>>,
|
||||
}
|
||||
|
||||
impl FileRetrievalService {
|
||||
/// Backward-compatible constructor (simple pass-through).
|
||||
/// Backward-compatible constructor (simple pass-through). Without the
|
||||
/// authorization engine, the `*_owned`/`*_with_perms` methods fail closed.
|
||||
/// Use `new_with_cache` in production.
|
||||
pub fn new(file_repository: Arc<FileBlobReadRepository>) -> Self {
|
||||
Self {
|
||||
file_read: file_repository,
|
||||
content_cache: None,
|
||||
transcode: None,
|
||||
authz: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor for blob-storage model: read + content cache + transcode.
|
||||
/// Constructor for blob-storage model: read + content cache + transcode +
|
||||
/// ReBAC authorization.
|
||||
pub fn new_with_cache(
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
content_cache: Arc<FileContentCache>,
|
||||
transcode: Arc<ImageTranscodeService>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_read,
|
||||
content_cache: Some(content_cache),
|
||||
transcode: Some(transcode),
|
||||
authz: Some(authz),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
file_id: &str,
|
||||
perm: Permission,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let authz = self.authz.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
||||
})?;
|
||||
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
authz
|
||||
.require(Subject::User(caller_id), perm, Resource::File(uuid))
|
||||
.await
|
||||
}
|
||||
|
||||
// ── private helpers ──────────────────────────────────────────
|
||||
|
||||
/// Try to transcode image content to WebP and return transcoded variant.
|
||||
@@ -203,12 +230,17 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
}
|
||||
|
||||
async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
let file = self.file_read.get_file_for_owner(id, caller_id).await?;
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
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
|
||||
// that surface its result to a user-driven request MUST resolve the
|
||||
// file via get_file_owned afterwards, or call authz.require directly.
|
||||
// (Tracked in the audit punch-list under "path-based lookups".)
|
||||
if let Some(file) = self.file_read.find_file_by_path(path).await? {
|
||||
return Ok(FileDto::from(file));
|
||||
}
|
||||
@@ -248,7 +280,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.file_read.verify_file_owner(id, caller_id).await?;
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
self.file_read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
@@ -272,7 +304,8 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
accept_webp: bool,
|
||||
prefer_original: bool,
|
||||
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
||||
let file = self.file_read.get_file_for_owner(id, caller_id).await?;
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
let dto = FileDto::from(file);
|
||||
self.optimized_inner(id, dto, accept_webp, prefer_original)
|
||||
.await
|
||||
@@ -307,8 +340,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
// Verify ownership first, then delegate to the unscoped stream
|
||||
self.file_read.verify_file_owner(id, caller_id).await?;
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::file_upload_service::FileUploadService;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Factory for creating file use case implementations
|
||||
pub struct AppFileUseCaseFactory {
|
||||
file_read_repository: Arc<FileBlobReadRepository>,
|
||||
file_write_repository: Arc<FileBlobWriteRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl AppFileUseCaseFactory {
|
||||
@@ -18,10 +20,12 @@ impl AppFileUseCaseFactory {
|
||||
pub fn new(
|
||||
file_read_repository: Arc<FileBlobReadRepository>,
|
||||
file_write_repository: Arc<FileBlobWriteRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_read_repository,
|
||||
file_write_repository,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,8 +40,13 @@ impl FileUseCaseFactory for AppFileUseCaseFactory {
|
||||
}
|
||||
|
||||
fn create_file_management_use_case(&self) -> Arc<FileManagementService> {
|
||||
Arc::new(FileManagementService::new(
|
||||
Arc::new(FileManagementService::with_trash(
|
||||
self.file_write_repository.clone(),
|
||||
None,
|
||||
Some(self.file_read_repository.clone()),
|
||||
None,
|
||||
None,
|
||||
self.authz.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Implementation of the use case for folder operations
|
||||
pub struct FolderService {
|
||||
folder_storage: Arc<FolderDbRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl FolderService {
|
||||
/// Creates a new folder service
|
||||
pub fn new(folder_storage: Arc<FolderDbRepository>) -> Self {
|
||||
Self { folder_storage }
|
||||
pub fn new(folder_storage: Arc<FolderDbRepository>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
folder_storage,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: parse a folder id string into a `Resource::Folder`. Returns
|
||||
/// `DomainError::not_found` on parse error (anti-enumeration — the same
|
||||
/// error as "folder does not exist").
|
||||
fn folder_resource(id: &str) -> Result<Resource, DomainError> {
|
||||
Uuid::parse_str(id)
|
||||
.map(Resource::Folder)
|
||||
.map_err(|_| DomainError::not_found("Folder", id))
|
||||
}
|
||||
|
||||
/// Creates a stub implementation for testing and middleware
|
||||
@@ -159,8 +175,13 @@ impl FolderUseCase for FolderService {
|
||||
"Root folder creation is reserved for registration",
|
||||
));
|
||||
};
|
||||
self.folder_storage
|
||||
.verify_owner(parent_id, caller_id)
|
||||
let parent_resource = Self::folder_resource(parent_id)?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
parent_resource,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
@@ -207,23 +228,21 @@ impl FolderUseCase for FolderService {
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
/// Gets a folder by its ID, enforcing that `caller_id` is the owner.
|
||||
/// Gets a folder by its ID, enforcing that `caller_id` has `Read` access
|
||||
/// (via ownership or a grant — including cascading from ancestor folders).
|
||||
async fn get_folder_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
let folder_dto = self.get_folder(id).await?;
|
||||
if folder_dto.owner_id.as_deref() != Some(&caller_id.to_string()) {
|
||||
tracing::warn!(
|
||||
"get_folder_owned: user '{}' attempted to access folder '{}' owned by '{:?}'",
|
||||
caller_id,
|
||||
id,
|
||||
folder_dto.owner_id
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
Ok(folder_dto)
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(id)?,
|
||||
)
|
||||
.await?;
|
||||
self.get_folder(id).await
|
||||
}
|
||||
|
||||
/// Gets a folder by its path
|
||||
@@ -395,14 +414,13 @@ impl FolderUseCase for FolderService {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Renames a folder after verifying ownership.
|
||||
/// Renames a folder after verifying the caller has `Update` permission.
|
||||
async fn rename_folder_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: RenameFolderDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
// Input validation
|
||||
if let Err(reason) = validate_storage_name(&dto.name) {
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid folder name '{}': {reason}",
|
||||
@@ -410,20 +428,14 @@ impl FolderUseCase for FolderService {
|
||||
)));
|
||||
}
|
||||
|
||||
// Verify the folder exists and belongs to the caller
|
||||
let existing_folder = self.folder_storage.get_folder(id).await?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Update,
|
||||
Self::folder_resource(id)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if existing_folder.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'",
|
||||
caller_id,
|
||||
id,
|
||||
existing_folder.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
|
||||
// Rename folder — UPDATE RETURNING gives us the updated row directly
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.rename_folder(id, dto.name)
|
||||
@@ -438,29 +450,25 @@ impl FolderUseCase for FolderService {
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
/// Moves a folder to a new parent after verifying ownership.
|
||||
/// Moves a folder to a new parent. Requires `Update` on the source and
|
||||
/// `Create` on the destination parent (if any).
|
||||
async fn move_folder_with_perms(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: MoveFolderDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
// Verify the source folder exists and belongs to the caller
|
||||
let source_folder = self.folder_storage.get_folder(id).await?;
|
||||
let source_resource = Self::folder_resource(id)?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Update,
|
||||
source_resource,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if source_folder.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'",
|
||||
caller_id,
|
||||
id,
|
||||
source_folder.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
|
||||
// If a parent_id is specified, verify it exists and belongs to the caller
|
||||
if let Some(parent_id) = &dto.parent_id {
|
||||
// Verify we are not trying to move the folder into itself or one of its descendants
|
||||
// Cannot move a folder into itself (cycle guard).
|
||||
if parent_id == id {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
@@ -468,27 +476,17 @@ impl FolderUseCase for FolderService {
|
||||
"Cannot move a folder into itself",
|
||||
));
|
||||
}
|
||||
|
||||
// Verify the destination exists and is owned by the caller
|
||||
let parent = self
|
||||
.folder_storage
|
||||
.get_folder(parent_id)
|
||||
.await
|
||||
.map_err(|_| DomainError::not_found("Folder", parent_id))?;
|
||||
if parent.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"move_folder: user '{}' attempted to move into folder '{}' owned by '{:?}'",
|
||||
caller_id,
|
||||
parent_id,
|
||||
parent.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", parent_id));
|
||||
}
|
||||
|
||||
// TODO: Ideally we should verify the entire hierarchy to prevent cycles
|
||||
let parent_resource = Self::folder_resource(parent_id)?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
parent_resource,
|
||||
)
|
||||
.await?;
|
||||
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
|
||||
}
|
||||
|
||||
// Move folder — UPDATE RETURNING gives us the updated row directly
|
||||
let parent_ref = dto.parent_id.as_deref();
|
||||
let folder = self
|
||||
.folder_storage
|
||||
@@ -504,22 +502,18 @@ impl FolderUseCase for FolderService {
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
/// Deletes a folder after verifying ownership.
|
||||
/// Deletes a folder after verifying the caller has `Delete` permission.
|
||||
/// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants`
|
||||
/// rows targeting the deleted folder automatically.
|
||||
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
// Verify the folder exists and belongs to the caller
|
||||
let folder = self.folder_storage.get_folder(id).await?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Delete,
|
||||
Self::folder_resource(id)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if folder.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'",
|
||||
caller_id,
|
||||
id,
|
||||
folder.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
|
||||
// Delete the folder
|
||||
self.folder_storage.delete_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
|
||||
Reference in New Issue
Block a user