feat(mounts): P2 — read-write REST for external mounts

Adds full CRUD on mount contents, mirroring the P1 read pattern (handlers/
services classify; authorization stays in the service via the mount-root
folder grant; the provider does the I/O).

- mkdir / rename / delete / move-within branch inside FolderService and
  FileManagementService (router injected into both)
- streaming upload via a new ExternalUploadService: the upload handler detects a
  mount destination BEFORE the CAS ingest and streams the multipart body
  straight to the provider (no BLAKE3/dedup). `write_stream` now takes a
  lifetime-bound boxed stream so the borrowing multipart field can be passed
  without buffering.
- deletes on mounts are permanent (no trash): the trash-first folder handler
  routes `ext:` ids straight to the provider delete; file delete goes through the
  branched delete_and_cleanup
- cross-backend move/copy (mount ↔ native, or between mounts) is forbidden
  (UnsupportedOperation); the mount root itself cannot be renamed/moved/deleted
- every mutation emits a `target:"audit" event="external_mount.write"` line
- shared mount_dto builders synthesize FolderDto/FileDto from a provider MountStat

Tests: 529 unit + integration tests for mkdir/rename/delete, file rename/delete,
streaming upload, cross-boundary forbid, and stranger-denied — all against real
Postgres + a real provider (testcontainers).
This commit is contained in:
Bradley Nelson
2026-06-25 00:30:10 -06:00
parent 3c31695579
commit 8e3e31da4d
10 changed files with 714 additions and 12 deletions
@@ -6,9 +6,13 @@ use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::file_ports::FileManagementUseCase;
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id};
use crate::application::services::mount_registry::MountConfig;
use crate::application::services::trash_service::TrashService;
use crate::common::errors::DomainError;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::external_mount_id::NodeId;
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;
@@ -31,6 +35,9 @@ pub struct FileManagementService {
authz: Arc<PgAclEngine>,
/// Lifecycle hook dispatcher — fired on file created (copy) and deleted.
file_lifecycle_hook: Option<Arc<dyn FileLifecycleHook>>,
/// External-mount classifier. `None` in stub/test construction → all ids
/// are treated as native.
mount_router: Option<Arc<MountRouter>>,
}
impl FileManagementService {
@@ -53,6 +60,7 @@ impl FileManagementService {
content_cache,
authz,
file_lifecycle_hook: None,
mount_router: None,
}
}
@@ -62,6 +70,59 @@ impl FileManagementService {
self
}
/// Injects the external-mount classifier so file mutations can branch
/// `ext:` ids to the provider.
pub fn with_mount_router(mut self, router: Arc<MountRouter>) -> Self {
self.mount_router = Some(router);
self
}
/// Classify an id via the mount router (if configured). Returns `Regular`
/// when no router is wired.
fn classify(&self, id: &str) -> ResolvedId {
match &self.mount_router {
Some(r) => r.classify(id),
None => ResolvedId::Regular,
}
}
/// Authorize a mutation inside a mount (gates on the mount-root folder).
async fn require_mount_perm(
&self,
cfg: &MountConfig,
perm: Permission,
caller_id: Uuid,
) -> Result<(), DomainError> {
self.authz
.require(
Subject::User(caller_id),
perm,
Resource::Folder(cfg.mount_id),
)
.await
}
/// Resolve a move destination within the same mount as `cfg`. Errors when
/// the destination is absent, native, or in a different mount.
fn mount_dest_node(
&self,
cfg: &MountConfig,
folder_id: Option<&str>,
) -> Result<NodeId, DomainError> {
let Some(folder_id) = folder_id else {
return Err(cross_boundary_move_err());
};
match self.classify(folder_id) {
ResolvedId::MountRoot { cfg: dest } if dest.mount_id == cfg.mount_id => {
Ok(NodeId::default())
}
ResolvedId::MountChild { cfg: dest, node_id } if dest.mount_id == cfg.mount_id => {
Ok(node_id)
}
_ => Err(cross_boundary_move_err()),
}
}
/// Engine check for a file resource. Parses the id into a `Uuid` and
/// requires the specified permission.
async fn require_file_perm(
@@ -255,6 +316,29 @@ impl FileManagementUseCase for FileManagementService {
caller_id: Uuid,
folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
// External mount: moves stay within one mount; cross-backend is forbidden.
match self.classify(file_id) {
ResolvedId::Regular => {
if let Some(dst) = folder_id.as_deref()
&& !matches!(self.classify(dst), ResolvedId::Regular)
{
return Err(cross_boundary_move_err());
}
}
ResolvedId::MountRoot { .. } => return Err(DomainError::not_found("File", file_id)),
ResolvedId::MountChild { cfg, node_id } => {
let dest = self.mount_dest_node(&cfg, folder_id.as_deref())?;
self.require_mount_perm(&cfg, Permission::Update, caller_id)
.await?;
self.require_mount_perm(&cfg, Permission::Create, caller_id)
.await?;
let stat = cfg.provider.move_within(&node_id, &dest).await?;
audit_mount_write("move", &cfg, caller_id, stat.node_id.as_str());
let parent = mount_parent_id(&cfg, stat.node_id.as_str());
return Ok(mount_file_dto(&cfg, &parent, &stat));
}
}
// Move = Update on the file + Create on the target folder (if any).
self.require_file_perm(file_id, Permission::Update, caller_id)
.await?;
@@ -285,12 +369,32 @@ impl FileManagementUseCase for FileManagementService {
caller_id: Uuid,
new_name: &str,
) -> Result<FileDto, DomainError> {
if let ResolvedId::MountChild { cfg, node_id } = self.classify(file_id) {
if let Err(reason) = validate_storage_name(new_name) {
return Err(DomainError::validation_error(format!(
"Invalid file name '{new_name}': {reason}"
)));
}
self.require_mount_perm(&cfg, Permission::Update, caller_id)
.await?;
let stat = cfg.provider.rename(&node_id, new_name).await?;
audit_mount_write("rename", &cfg, caller_id, stat.node_id.as_str());
let parent = mount_parent_id(&cfg, stat.node_id.as_str());
return Ok(mount_file_dto(&cfg, &parent, &stat));
}
self.require_file_perm(file_id, Permission::Update, caller_id)
.await?;
self.rename_file(file_id, new_name, caller_id).await
}
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
if let ResolvedId::MountChild { cfg, node_id } = self.classify(id) {
self.require_mount_perm(&cfg, Permission::Delete, caller_id)
.await?;
cfg.provider.delete(&node_id).await?;
audit_mount_write("delete", &cfg, caller_id, node_id.as_str());
return Ok(());
}
self.require_file_perm(id, Permission::Delete, caller_id)
.await?;
self.delete_file(id).await
@@ -307,6 +411,15 @@ impl FileManagementUseCase for FileManagementService {
id: &str,
caller_id: Uuid,
) -> Result<bool, DomainError> {
// External mount: permanent provider delete (mounts have no trash).
if let ResolvedId::MountChild { cfg, node_id } = self.classify(id) {
self.require_mount_perm(&cfg, Permission::Delete, caller_id)
.await?;
cfg.provider.delete(&node_id).await?;
audit_mount_write("delete", &cfg, caller_id, node_id.as_str());
return Ok(false); // permanently deleted (no trash)
}
self.require_file_perm(id, Permission::Delete, caller_id)
.await?;
// Step 1: Try trash (soft delete — file row stays, blob stays referenced)
@@ -357,3 +470,12 @@ impl FileManagementUseCase for FileManagementService {
.await
}
}
/// Error for a move/copy that would cross a storage backend boundary
/// (mount ↔ native, or between two different mounts). Forbidden in v1.
fn cross_boundary_move_err() -> DomainError {
DomainError::operation_not_supported(
"File",
"moving between external mounts and regular storage is not supported",
)
}