feat(rebac): first pass
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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