feat(rebac): first pass

This commit is contained in:
Edouard Vanbelle
2026-05-20 22:56:00 +02:00
parent 2c53f99089
commit cba9be8c21
22 changed files with 2058 additions and 153 deletions
+222
View File
@@ -0,0 +1,222 @@
//! DTOs for the ReBAC `/api/grants` REST endpoints.
//!
//! The wire shapes are intentionally separate from the domain types
//! (`Subject`, `Resource`, `Permission`, `Grant`) so that domain stays
//! storage-agnostic and DTOs can evolve with the HTTP contract.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
// ════════════════════════════════════════════════════════════════════════════
// Subject / Resource / Permission DTOs
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SubjectTypeDto {
User,
Group,
Token,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SubjectDto {
#[serde(rename = "type")]
pub kind: SubjectTypeDto,
pub id: Uuid,
}
impl From<SubjectDto> for Subject {
fn from(dto: SubjectDto) -> Self {
match dto.kind {
SubjectTypeDto::User => Subject::User(dto.id),
SubjectTypeDto::Group => Subject::Group(dto.id),
SubjectTypeDto::Token => Subject::Token(dto.id),
SubjectTypeDto::External => Subject::External(dto.id),
}
}
}
impl From<Subject> for SubjectDto {
fn from(s: Subject) -> Self {
let (kind, id) = match s {
Subject::User(id) => (SubjectTypeDto::User, id),
Subject::Group(id) => (SubjectTypeDto::Group, id),
Subject::Token(id) => (SubjectTypeDto::Token, id),
Subject::External(id) => (SubjectTypeDto::External, id),
};
SubjectDto { kind, id }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ResourceTypeDto {
Folder,
File,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ResourceDto {
#[serde(rename = "type")]
pub kind: ResourceTypeDto,
pub id: Uuid,
}
impl From<ResourceDto> for Resource {
fn from(dto: ResourceDto) -> Self {
match dto.kind {
ResourceTypeDto::Folder => Resource::Folder(dto.id),
ResourceTypeDto::File => Resource::File(dto.id),
}
}
}
impl From<Resource> for ResourceDto {
fn from(r: Resource) -> Self {
let (kind, id) = match r {
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
Resource::File(id) => (ResourceTypeDto::File, id),
};
ResourceDto { kind, id }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PermissionDto {
Read,
Create,
Share,
Comment,
Delete,
Update,
}
impl From<PermissionDto> for Permission {
fn from(p: PermissionDto) -> Self {
match p {
PermissionDto::Read => Permission::Read,
PermissionDto::Create => Permission::Create,
PermissionDto::Share => Permission::Share,
PermissionDto::Comment => Permission::Comment,
PermissionDto::Delete => Permission::Delete,
PermissionDto::Update => Permission::Update,
}
}
}
impl From<Permission> for PermissionDto {
fn from(p: Permission) -> Self {
match p {
Permission::Read => PermissionDto::Read,
Permission::Create => PermissionDto::Create,
Permission::Share => PermissionDto::Share,
Permission::Comment => PermissionDto::Comment,
Permission::Delete => PermissionDto::Delete,
Permission::Update => PermissionDto::Update,
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Roles (DTO-layer sugar)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Viewer,
Commenter,
Editor,
Manager,
Admin,
}
impl Role {
/// Expands a role into its constituent raw permissions. Storage and
/// engine know nothing about roles — the server normalizes here before
/// writing rows.
pub fn expand(self) -> &'static [Permission] {
match self {
Role::Viewer => &[Permission::Read],
Role::Commenter => &[Permission::Read, Permission::Comment],
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
Role::Manager => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
],
Role::Admin => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
Permission::Share,
Permission::Delete,
],
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// Request DTOs
// ════════════════════════════════════════════════════════════════════════════
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
/// Server-side validation requires exactly one of the two to be present.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateGrantDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
}
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleDto {
pub subject: SubjectDto,
pub resource: ResourceDto,
pub role: Role,
}
// ════════════════════════════════════════════════════════════════════════════
// Response DTOs
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GrantDto {
pub id: Uuid,
pub subject: SubjectDto,
pub resource: ResourceDto,
pub permission: PermissionDto,
pub granted_by: Uuid,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
impl From<Grant> for GrantDto {
fn from(g: Grant) -> Self {
Self {
id: g.id,
subject: g.subject.into(),
resource: g.resource.into(),
permission: g.permission.into(),
granted_by: g.granted_by,
granted_at: g.granted_at,
}
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
pub mod folder_listing_dto;
pub mod grant_dto;
pub mod i18n_dto;
pub mod pagination;
pub mod playlist_dto;
@@ -0,0 +1,87 @@
//! Authorization port — the trait every service depends on for permission
//! decisions. Implementations: `PgAclEngine` (v1 default), `OpenFgaEngine`
//! (future). A `CachedAuthorizationEngine` decorator over either is planned
//! as a future optimization.
//!
//! Architectural rule (see CLAUDE.md):
//! **AuthZ is enforced exclusively in the application service layer.**
//! Handlers authenticate the caller and pass `caller_id` to the service;
//! they never call this trait directly.
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Returns true if `subject` has `permission` on `resource`, considering
/// owner short-circuit AND cascading from folder ancestors.
///
/// `check` never errors for "permission denied" — that's a `false` return.
/// `Err` is reserved for infrastructure failures (DB down, etc.).
async fn check(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<bool, DomainError>;
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
/// `DomainError::not_found` when denied (anti-enumeration — same error as
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
async fn require(
&self,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<(), DomainError> {
if self.check(subject, permission, resource).await? {
Ok(())
} else {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
};
Err(DomainError::not_found(kind, id.to_string()))
}
}
/// Resources explicitly granted to `subject`. Direct grants only — no
/// cascade expansion. Used by `GET /api/grants/incoming`.
async fn list_incoming_grants(
&self,
subject: Subject,
permission_filter: Option<Permission>,
) -> Result<Vec<Grant>, DomainError>;
/// All grants on a specific resource (for "Manage sharing" UI). Caller
/// must verify the caller has `Share` on the resource before invoking.
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError>;
/// Grants Outgoing — grants created by `granted_by`. Used by
/// `GET /api/grants/outgoing` ("things I've shared with others").
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError>;
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
/// constraint and the existing row is returned.
async fn grant(
&self,
granted_by: Uuid,
subject: Subject,
permission: Permission,
resource: Resource,
) -> Result<Grant, DomainError>;
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
/// the row existed (idempotent revoke).
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
/// Removes every grant whose `resource` matches. Called by lifecycle
/// hooks when a resource is permanently deleted. Returns the count of
/// rows removed.
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError>;
/// Removes every grant whose `subject` matches. Called when a user/token
/// /group is deleted. Returns the count of rows removed.
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
}
+1
View File
@@ -1,4 +1,5 @@
pub mod auth_ports;
pub mod authorization_ports;
pub mod blob_lifecycle;
pub mod blob_storage_ports;
pub mod cache_ports;
@@ -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(),
))
}
}
+73 -79
View File
@@ -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",