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
+14 -2
View File
@@ -524,7 +524,7 @@ impl AppServiceFactory {
let folder_service = Arc::new(FolderService::new(
repos.folder_repository.clone(),
authz.clone(),
mount_router,
mount_router.clone(),
));
// Built before the upload/management services so the plugin lifecycle
@@ -581,7 +581,15 @@ impl AppServiceFactory {
Some(core.file_content_cache.clone()),
authz.clone(),
)
.with_file_lifecycle_hook(file_lifecycle.clone()),
.with_file_lifecycle_hook(file_lifecycle.clone())
.with_mount_router(mount_router.clone()),
);
// Streams uploads to external mount providers (bypasses the CAS).
let external_upload_service = Arc::new(
crate::application::services::external_upload_service::ExternalUploadService::new(
authz.clone(),
),
);
let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new(
@@ -618,6 +626,7 @@ impl AppServiceFactory {
delta_upload_service,
file_retrieval_service,
file_management_service,
external_upload_service,
file_use_case_factory,
i18n_service,
trash_service, // Already set via parameter
@@ -1876,6 +1885,9 @@ pub struct ApplicationServices {
Arc<crate::application::services::delta_upload_service::DeltaUploadService>,
pub file_retrieval_service: Arc<FileRetrievalService>,
pub file_management_service: Arc<FileManagementService>,
/// Streams uploads straight to an external mount provider (bypasses the CAS).
pub external_upload_service:
Arc<crate::application::services::external_upload_service::ExternalUploadService>,
pub file_use_case_factory: Arc<dyn FileUseCaseFactory>,
pub i18n_service: Arc<I18nApplicationService>,
pub trash_service: Option<Arc<TrashService>>,