From f8b30e78a6be004d91721098a6eb9fd579cb9336 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 20 May 2026 12:48:06 +0200 Subject: [PATCH 01/13] refactor(create_folder): add an ownership check while creating a folder + refactor code --- src/application/ports/inbound.rs | 6 +- src/application/services/batch_operations.rs | 9 +-- .../services/file_management_service.rs | 57 +++++++------------ src/application/services/folder_service.rs | 31 +++++----- src/common/stubs.rs | 6 +- .../repositories/pg/folder_db_repository.rs | 16 ++++++ src/interfaces/api/handlers/folder_handler.rs | 20 +------ src/interfaces/api/handlers/webdav_handler.rs | 17 +++--- src/interfaces/nextcloud/webdav_handler.rs | 3 +- 9 files changed, 76 insertions(+), 89 deletions(-) diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 3a7380ac..86816eeb 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -13,7 +13,11 @@ use crate::common::errors::DomainError; /// Primary port for folder operations pub trait FolderUseCase: Send + Sync + 'static { /// Creates a new folder - async fn create_folder(&self, dto: CreateFolderDto) -> Result; + async fn create_folder( + &self, + dto: CreateFolderDto, + caller_id: Uuid, + ) -> Result; /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result; diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 4d161272..7a12c4f2 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -975,18 +975,11 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); async move { - // If a parent is specified, verify the caller owns it - if let Some(ref pid) = parent_id - && let Err(e) = folder_service.get_folder_owned(pid, user_id).await - { - let id = format!("{}:{}", name, pid); - return (id, Err(e)); - } let dto = crate::application::dtos::folder_dto::CreateFolderDto { name: name.clone(), parent_id: parent_id.clone(), }; - let create_result = folder_service.create_folder(dto).await; + let create_result = folder_service.create_folder(dto, user_id).await; let id = format!("{}:{}", name, parent_id.unwrap_or_default()); (id, create_result) } diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 7bec7187..77c53795 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -82,33 +82,26 @@ impl FileManagementService { } /// Verifies that the target folder is owned by the caller. - /// If folder_id is None (root), ownership is implicitly granted. + /// + /// `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( &self, - folder_id: &Option, + folder_id: Option<&str>, caller_id: Uuid, ) -> Result<(), DomainError> { - let folder_id = match folder_id { - Some(id) => id, - None => return Ok(()), // Moving to root is always allowed + let Some(target) = folder_id else { + // TODO: File creation to root is currently allowed, check is this policy is relevant + return Ok(()); }; - - if let Some(folder_repo) = &self.folder_repo { - let folder_owner = folder_repo.get_folder_user_id(folder_id).await?; - if folder_owner != caller_id { - return Err(DomainError::not_found( - "Folder", - "Target folder not found or access denied", - )); - } - Ok(()) - } else { - // Fallback: no folder repo injected — deny by default (fail-closed) - Err(DomainError::internal_error( + 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 } } @@ -151,7 +144,7 @@ impl FileManagementUseCase for FileManagementService { // 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, caller_id) + self.verify_target_folder_owner(folder_id.as_deref(), caller_id) .await?; self.move_file(file_id, folder_id).await } @@ -192,7 +185,7 @@ impl FileManagementUseCase for FileManagementService { target_folder_id: Option, ) -> Result { self.verify_owner(file_id, caller_id).await?; - self.verify_target_folder_owner(&target_folder_id, caller_id) + self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id) .await?; self.copy_file(file_id, target_folder_id).await } @@ -334,21 +327,11 @@ impl FileManagementUseCase for FileManagementService { target_parent_id: Option, dest_name: Option, ) -> Result { - if let Some(folder_repo) = &self.folder_repo { - let owner = folder_repo.get_folder_user_id(source_folder_id).await?; - if owner != caller_id { - return Err(DomainError::not_found( - "Folder", - "Source folder not found or access denied", - )); - } - } else { - return Err(DomainError::internal_error( - "FileManagement", - "Folder ownership verification unavailable", - )); - } - self.verify_target_folder_owner(&target_parent_id, caller_id) + // 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) + .await?; + self.verify_target_folder_owner(target_parent_id.as_deref(), caller_id) .await?; self.copy_folder_tree(source_folder_id, target_parent_id, dest_name) .await diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index d57f805f..b8e910b0 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -25,7 +25,11 @@ impl FolderService { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { - async fn create_folder(&self, _dto: CreateFolderDto) -> Result { + async fn create_folder( + &self, + _dto: CreateFolderDto, + _user_id: Uuid, + ) -> Result { Ok(FolderDto::empty()) } @@ -134,8 +138,11 @@ impl FolderService { impl FolderUseCase for FolderService { /// Creates a new folder - async fn create_folder(&self, dto: CreateFolderDto) -> Result { - // Input validation + async fn create_folder( + &self, + dto: CreateFolderDto, + caller_id: Uuid, + ) -> Result { if let Err(reason) = validate_storage_name(&dto.name) { return Err(DomainError::validation_error(format!( "Invalid folder name '{}': {reason}", @@ -143,21 +150,19 @@ impl FolderUseCase for FolderService { ))); } - // If a parent_id is provided, verify it exists - if let Some(parent_id) = &dto.parent_id { - let parent_exists = self.folder_storage.get_folder(parent_id).await.is_ok(); - if !parent_exists { - return Err(DomainError::not_found("Folder", parent_id)); - } - } + let Some(parent_id) = dto.parent_id.as_deref() else { + return Err(DomainError::validation_error( + "Root folder creation is reserved for registration", + )); + }; + self.folder_storage + .verify_owner(parent_id, caller_id) + .await?; - // Create the folder let folder = self .folder_storage .create_folder(dto.name, dto.parent_id) .await?; - - // Convert to DTO Ok(FolderDto::from(folder)) } diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 01328051..43fd0f5f 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -353,7 +353,11 @@ impl I18nService for StubI18nService { pub struct StubFolderUseCase; impl FolderUseCase for StubFolderUseCase { - async fn create_folder(&self, _dto: CreateFolderDto) -> Result { + async fn create_folder( + &self, + _dto: CreateFolderDto, + _user_id: Uuid, + ) -> Result { Ok(FolderDto::default()) } diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index ca69564b..8001204b 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1044,4 +1044,20 @@ impl FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", folder_id)) } + + /// Verifies that `folder_id` is owned by `owner_id`. + /// + /// Returns `DomainError::not_found(...)` for both "folder missing" and + /// "folder owned by someone else" — same error to avoid leaking the + /// existence of resources belonging to other users. + pub async fn verify_owner(&self, folder_id: &str, owner_id: Uuid) -> Result<(), DomainError> { + let actual = self.get_folder_user_id(folder_id).await?; + if actual != owner_id { + return Err(DomainError::not_found( + "Folder", + "Target folder not found or access denied", + )); + } + Ok(()) + } } diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 8caea7b8..45cc0276 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -76,25 +76,7 @@ impl FolderHandler { } } - // ── SECURITY: Verify parent folder ownership (IDOR V-04 fix) ── - if let Some(ref parent_id) = dto.parent_id { - use crate::application::ports::inbound::FolderUseCase; - if service - .get_folder_owned(parent_id, auth_user.id) - .await - .is_err() - { - tracing::warn!( - "create_folder: user '{}' attempted to create folder in parent '{}' owned by another user", - auth_user.username, - parent_id, - ); - return AppError::not_found(format!("Parent folder not found: {}", parent_id)) - .into_response(); - } - } - - match service.create_folder(dto).await { + match service.create_folder(dto, auth_user.id).await { Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 826c8664..2a2f58b3 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -999,6 +999,7 @@ async fn handle_mkcol( req: Request, path: String, ) -> Result, AppError> { + let user = extract_user(&req)?; let folder_service = &state.applications.folder_service; if path.is_empty() || path == "/" { @@ -1041,15 +1042,13 @@ async fn handle_mkcol( name: segment.to_string(), parent_id: parent_id.clone(), }; + // Propagate DomainError -> AppError so NotFound/Conflict map to + // their proper HTTP status codes (was: blanket 500 swallowed + // ownership-rejection NotFound from verify_owner). let created = folder_service - .create_folder(create_dto) + .create_folder(create_dto, user.id) .await - .map_err(|e| { - AppError::internal_error(format!( - "Failed to create folder '{}': {}", - accumulated_path, e - )) - })?; + .map_err(AppError::from)?; parent_id = Some(created.id); } } @@ -1551,7 +1550,7 @@ async fn handle_copy( parent_id: target_parent_id, }; folder_service - .create_folder(create_dto) + .create_folder(create_dto, user.id) .await .map_err(|e| { AppError::internal_error(format!( @@ -1655,7 +1654,7 @@ async fn handle_copy( parent_id: target_parent_id, }; folder_service - .create_folder(create_dto) + .create_folder(create_dto, user.id) .await .map_err(|e| { AppError::internal_error(format!( diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index a383ad05..dd133112 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -551,6 +551,7 @@ async fn handle_put( .map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?; // Update audio metadata for supported audio files. + // TODO: use notification service or hook if let Some(ref audio_service) = state.applications.audio_metadata_service && let Ok(file_id) = uuid::Uuid::parse_str(&updated.id) { @@ -655,7 +656,7 @@ async fn handle_mkcol( name: segment.to_string(), parent_id: Some(parent_id.clone()), }; - match folder_service.create_folder(dto).await { + match folder_service.create_folder(dto, user.id).await { Ok(created) => { parent_id = created.id.clone(); } From ac42a6d3cc1ab0c521f87ec66964fa40626dc261 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 20 May 2026 12:58:14 +0200 Subject: [PATCH 02/13] test(api): check right management for folder creation and folder move + check also webdsav MKCOL protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit │ Steps 1-6 │ Setup: admin's resources + create bob + bob's home folder │ │ Step 7 │ REST: bob can't create a folder inside admin's home → 404 │ │ Step 8 │ REST: bob can't create inside admin's private folder → 404 │ │ Step 9 │ REST: parent_id: null auto-resolves to bob's home (documents the convenience) │ │ Step 10 │ REST: positive control — bob creates in his own home → 201 │ │ Step 12 │ REST: bob can't move his file into admin's folder → 404 │ │ Step 13 │ REST: bob moves file to root (null) → 200 (legitimate root state) │ │ Step 14 │ REST: bob can't read admin's file → 404 │ │ Step 15 │ REST: admin's tree integrity preserved │ │ Step 16 │ WebDAV: path-prefix isolation rewrites cross-user paths into caller's tree │ │ Step 17 │ WebDAV: positive control MKCOL in bob's own tree → 201 │ │ Step 18 │ WebDAV: bob's home contains the rewritten "My Folder - admin" sub-folder, proving the isolation rerouted the attack │ │ Step 19 │ WebDAV: admin's tree never sees bob's WebDAV traffic │ --- tests/api/permissions.hurl | 344 +++++++++++++++++++++++++++++++++++++ tests/api/run.sh | 3 +- 2 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 tests/api/permissions.hurl diff --git a/tests/api/permissions.hurl b/tests/api/permissions.hurl new file mode 100644 index 00000000..e30bb5b3 --- /dev/null +++ b/tests/api/permissions.hurl @@ -0,0 +1,344 @@ +# ============================================================= +# OxiCloud – Cross-user permission / IDOR scenarios +# ============================================================= +# Verifies the ownership checks added to FolderService::create_folder +# and FileManagementService move/copy/rename, plus the shared +# FolderDbRepository::verify_owner helper. +# +# Plan reference: /Users/ed/.claude/plans/compiled-shimmying-bonbon.md +# — "Verification → 2. Manual integration tests" +# +# Run via tests/api/run.sh; must be ordered LAST in the runner because +# it creates a second user (bob) and writes into admin's home folder. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Login as admin (the user created by setup.hurl) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – Capture admin's home folder +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 – Admin creates a private folder inside their home +# This is the resource bob will attempt to attack. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "name": "admin-private-folder", + "parent_id": "{{admin_home_id}}" +} + +HTTP 201 +[Captures] +admin_private_id: jsonpath "$.id" +[Asserts] +jsonpath "$.name" == "admin-private-folder" +jsonpath "$.parent_id" == {{admin_home_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 4 – Admin uploads a file into their home +# This is the file bob will attempt to access. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{admin_home_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +admin_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Admin creates user bob (via /api/admin/users) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "bob", + "password": "BobPassword1!", + "email": "bob@example.com", + "role": "user" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 6 – Login as bob, capture his token + home folder +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "bob", + "password": "BobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + +GET {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_home_id: jsonpath "$[0].id" +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].parent_id" == null + + +# ═════════════════════════════════════════════════════════════ +# IDOR tests — every request below uses bob's token +# ═════════════════════════════════════════════════════════════ + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Bob attempts to create a folder inside admin's home +# Expected: 404 (NotFound, not 403, to avoid leaking +# the existence of admin's folder). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-attack-1", + "parent_id": "{{admin_home_id}}" +} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 8 – Bob attempts to create a folder inside admin's +# private folder. Same expectation as Step 7. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-attack-2", + "parent_id": "{{admin_private_id}}" +} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 9 – Bob omits parent_id (null). The REST handler +# auto-resolves null to the caller's home folder +# (folder_handler.rs:55-77), so the request succeeds +# and the folder lands in bob's home — NOT at the +# database root. The service-level validation_error +# ("Root folder creation is reserved for registration") +# is defense-in-depth for callers that bypass this +# handler convenience. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-auto-resolved", + "parent_id": null +} + +HTTP 201 +[Asserts] +jsonpath "$.name" == "bob-auto-resolved" +jsonpath "$.parent_id" == {{bob_home_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 10 – Positive control: bob CAN create a folder inside +# his own home. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "bob-own-folder", + "parent_id": "{{bob_home_id}}" +} + +HTTP 201 +[Captures] +bob_folder_id: jsonpath "$.id" +[Asserts] +jsonpath "$.name" == "bob-own-folder" +jsonpath "$.parent_id" == {{bob_home_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 11 – Bob uploads a file into his own home (for the +# file-move tests below). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{bob_home_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +bob_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 12 – Bob attempts to move his own file into admin's +# private folder. He owns the file but not the target +# → verify_target_folder_owner rejects with 404. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/files/{{bob_file_id}}/move +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "folder_id": "{{admin_private_id}}" +} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 13 – Bob moves his file to folder_id: null (his root +# namespace). storage.files.folder_id IS NULL is a +# legitimate state — verify_target_folder_owner +# short-circuits to Ok(()) when target is None. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/files/{{bob_file_id}}/move +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "folder_id": null +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == {{bob_file_id}} +jsonpath "$.folder_id" == null + + +# ───────────────────────────────────────────────────────────── +# Step 14 – Bob attempts to access admin's file directly. +# verify_owner on the file (not the folder) catches +# this — IDOR on file reads, also 404. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/files/{{admin_file_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 +[Asserts] +jsonpath "$.error_type" == "Not Found" + + +# ───────────────────────────────────────────────────────────── +# Step 15 – Admin's private folder still exists & is untouched. +# Bob's attacks must not have polluted admin's tree. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders/{{admin_home_id}}/contents +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains {{admin_private_id}} +jsonpath "$[*].name" not contains "bob-attack-1" +jsonpath "$[*].name" not contains "bob-attack-2" + + +# ═════════════════════════════════════════════════════════════ +# WebDAV MKCOL — namespace isolation +# ═════════════════════════════════════════════════════════════ +# WebDAV requests are isolated per-user by `resolve_webdav_path` +# (webdav_handler.rs:189). If the requested path doesn't begin +# with the caller's home folder name ("My Folder - "), +# the handler silently prefixes the caller's home folder path +# onto the front. Effect: any WebDAV path a client sends is +# always resolved INSIDE the caller's own tree, regardless of +# what they wrote. +# +# These tests assert the isolation works (regression guard) and +# that the service-level verify_owner still acts as +# defense-in-depth for the legitimate path. + + +# ───────────────────────────────────────────────────────────── +# Step 16 – Bob crafts a path that looks like it targets admin's +# home. The WebDAV handler rewrites the path to live +# under bob's home, so the request succeeds (201) but +# the new folders land in BOB's tree — never admin's. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/My%20Folder%20-%20admin/bob-webdav-attack +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 17 – Positive control: bob MKCOL inside his own home. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/My%20Folder%20-%20bob/bob-webdav-own +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 18 – Bob's home now contains: +# - "bob-webdav-own" (from Step 17, normal MKCOL) +# - "My Folder - admin" (from Step 16 — the prefix +# rewrite turned admin's home name into a literal +# sub-folder name inside bob's tree). +# This proves the path prefix re-rooted the attack +# into bob's own namespace. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders/{{bob_home_id}}/contents +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].name" contains "bob-webdav-own" +jsonpath "$[*].name" contains "My Folder - admin" + + +# ───────────────────────────────────────────────────────────── +# Step 19 – Admin's tree is unchanged by bob's WebDAV traffic. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders/{{admin_home_id}}/contents +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].name" not contains "bob-webdav-attack" +jsonpath "$[*].name" not contains "bob-webdav-own" diff --git a/tests/api/run.sh b/tests/api/run.sh index 5f660b71..48e9f438 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -96,7 +96,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/recent.hurl" \ "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ - "$API_DIR/contacts.hurl" + "$API_DIR/contacts.hurl" \ + "$API_DIR/permissions.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" From dfb082fdf4f5259f0b1aac7c0b995f70be7eda9c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 20 May 2026 15:39:53 +0200 Subject: [PATCH 03/13] refactor(server): file_management_service: move all method without owner check into private, add folder_ports --- src/application/ports/file_ports.rs | 56 ++---- src/application/ports/folder_ports.rs | 94 +++++++++ src/application/ports/inbound.rs | 86 --------- src/application/ports/mod.rs | 1 + .../services/auth_application_service.rs | 2 +- src/application/services/batch_operations.rs | 26 ++- .../services/file_management_service.rs | 179 +++++++++--------- src/application/services/folder_service.rs | 38 ++-- .../services/idor_protection_test.rs | 4 +- .../services/share_browse_service.rs | 2 +- src/common/stubs.rs | 58 +++--- src/infrastructure/services/zip_service.rs | 2 +- src/interfaces/api/handlers/file_handler.rs | 18 +- src/interfaces/api/handlers/folder_handler.rs | 15 +- src/interfaces/api/handlers/webdav_handler.rs | 42 ++-- src/interfaces/nextcloud/report_handler.rs | 3 +- src/interfaces/nextcloud/webdav_handler.rs | 20 +- 17 files changed, 318 insertions(+), 328 deletions(-) create mode 100644 src/application/ports/folder_ports.rs diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 317b84fc..bc2be325 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -254,58 +254,34 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { } } -// ───────────────────────────────────────────────────── -// Management port (delete, move) -// ───────────────────────────────────────────────────── - /// Primary port for file management operations pub trait FileManagementUseCase: Send + Sync + 'static { - /// Moves a file to another folder (system/internal — no ownership check). - async fn move_file( - &self, - file_id: &str, - folder_id: Option, - ) -> Result; - /// Moves a file, enforcing that `caller_id` is the owner. - async fn move_file_owned( + async fn move_file_with_perms( &self, file_id: &str, caller_id: Uuid, folder_id: Option, ) -> Result; - /// Copies a file to another folder (zero-copy with dedup). - async fn copy_file( - &self, - file_id: &str, - target_folder_id: Option, - ) -> Result; - /// Copies a file, enforcing that `caller_id` is the owner. - async fn copy_file_owned( + async fn copy_file_with_perms( &self, file_id: &str, caller_id: Uuid, target_folder_id: Option, ) -> Result; - /// Renames a file (system/internal — no ownership check). - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; - /// Renames a file, enforcing that `caller_id` is the owner. - async fn rename_file_owned( + async fn rename_file_with_perms( &self, file_id: &str, caller_id: Uuid, new_name: &str, ) -> Result; - /// Deletes a file (system/internal — no ownership check). - async fn delete_file(&self, id: &str) -> Result<(), DomainError>; - /// Deletes a file, enforcing that `caller_id` is the owner. - async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; + async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; /// Smart delete: trash-first with dedup reference cleanup. /// @@ -314,30 +290,22 @@ pub trait FileManagementUseCase: Send + Sync + 'static { /// 3. Decrements the dedup reference count for the content hash. /// /// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted. - async fn delete_with_cleanup(&self, id: &str, user_id: Uuid) -> Result; + async fn delete_and_cleanup_with_perms( + &self, + id: &str, + user_id: Uuid, + ) -> Result; /// Copies an entire folder subtree atomically (WebDAV COPY Depth: infinity). + /// enforcing that `caller_id` owns both the source folder + /// and the target parent folder. /// /// Creates a copy of `source_folder_id` (with optional name override) under /// `target_parent_id`, including ALL sub-folders and files. Files are /// zero-copy (blob ref_counts incremented in batch). /// /// Default: returns error (only available with PostgreSQL backend). - async fn copy_folder_tree( - &self, - _source_folder_id: &str, - _target_parent_id: Option, - _dest_name: Option, - ) -> Result { - Err(DomainError::internal_error( - "FileManagement", - "copy_folder_tree not implemented", - )) - } - - /// Copies a folder tree, enforcing that `caller_id` owns both the source folder - /// and the target parent folder. - async fn copy_folder_tree_owned( + async fn copy_folder_tree_with_perms( &self, source_folder_id: &str, caller_id: Uuid, diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs new file mode 100644 index 00000000..1e736adc --- /dev/null +++ b/src/application/ports/folder_ports.rs @@ -0,0 +1,94 @@ +/// Primary port for folder operations +use uuid::Uuid; + +use crate::application::dtos::folder_dto::{ + CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, +}; + +use crate::common::errors::DomainError; + +pub trait FolderUseCase: Send + Sync + 'static { + /// Creates a new folder + async fn create_folder_with_perms( + &self, + dto: CreateFolderDto, + caller_id: Uuid, + ) -> Result; + + /// Gets a folder by its ID + async fn get_folder(&self, id: &str) -> Result; + + /// Gets a folder by its ID, enforcing that `caller_id` is the owner. + /// + /// Returns `NotFound` if the folder does not exist **or** belongs to + /// another user. All user-facing handlers should use this method. + async fn get_folder_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result; + + /// Gets a folder by its path + async fn get_folder_by_path(&self, path: &str) -> Result; + + /// Lists folders within a parent folder + async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; + + /// Lists folders scoped to a specific owner (for user-facing endpoints). + /// At root level, only returns folders belonging to this user. + async fn list_folders_for_owner( + &self, + parent_id: Option<&str>, + owner_id: Uuid, + ) -> Result, DomainError>; + + /// Lists folders with pagination + async fn list_folders_paginated( + &self, + parent_id: Option<&str>, + pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result, DomainError>; + + /// Lists folders with pagination, scoped to a specific owner. + async fn list_folders_for_owner_paginated( + &self, + parent_id: Option<&str>, + owner_id: Uuid, + pagination: &crate::application::dtos::pagination::PaginationRequestDto, + ) -> Result, DomainError>; + + /// Renames a folder (ownership verified against caller_id) + async fn rename_folder_with_perms( + &self, + id: &str, + dto: RenameFolderDto, + caller_id: Uuid, + ) -> Result; + + /// Moves a folder to another parent (ownership verified against caller_id) + async fn move_folder_with_perms( + &self, + id: &str, + dto: MoveFolderDto, + caller_id: Uuid, + ) -> Result; + + /// Deletes a folder (ownership verified against caller_id) + async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; + + /// Creates a root-level home folder for a user during registration. + async fn create_home_folder( + &self, + user_id: Uuid, + name: String, + ) -> Result; + + /// Lists every folder in a subtree rooted at `folder_id` (inclusive), + /// ordered by path. Uses ltree `<@` — single GiST-indexed query. + /// + /// Default: returns an empty vec (stubs / mocks). + async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { + let _ = folder_id; + Ok(Vec::new()) + } +} diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 86816eeb..96d71970 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -2,97 +2,11 @@ use std::sync::Arc; use uuid::Uuid; -use crate::application::dtos::folder_dto::{ - CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, -}; use crate::application::dtos::search_dto::{ SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, }; use crate::common::errors::DomainError; -/// Primary port for folder operations -pub trait FolderUseCase: Send + Sync + 'static { - /// Creates a new folder - async fn create_folder( - &self, - dto: CreateFolderDto, - caller_id: Uuid, - ) -> Result; - - /// Gets a folder by its ID - async fn get_folder(&self, id: &str) -> Result; - - /// Gets a folder by its ID, enforcing that `caller_id` is the owner. - /// - /// Returns `NotFound` if the folder does not exist **or** belongs to - /// another user. All user-facing handlers should use this method. - async fn get_folder_owned(&self, id: &str, caller_id: Uuid) -> Result; - - /// Gets a folder by its path - async fn get_folder_by_path(&self, path: &str) -> Result; - - /// Lists folders within a parent folder - async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; - - /// Lists folders scoped to a specific owner (for user-facing endpoints). - /// At root level, only returns folders belonging to this user. - async fn list_folders_for_owner( - &self, - parent_id: Option<&str>, - owner_id: Uuid, - ) -> Result, DomainError>; - - /// Lists folders with pagination - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - pagination: &crate::application::dtos::pagination::PaginationRequestDto, - ) -> Result, DomainError>; - - /// Lists folders with pagination, scoped to a specific owner. - async fn list_folders_for_owner_paginated( - &self, - parent_id: Option<&str>, - owner_id: Uuid, - pagination: &crate::application::dtos::pagination::PaginationRequestDto, - ) -> Result, DomainError>; - - /// Renames a folder (ownership verified against caller_id) - async fn rename_folder( - &self, - id: &str, - dto: RenameFolderDto, - caller_id: Uuid, - ) -> Result; - - /// Moves a folder to another parent (ownership verified against caller_id) - async fn move_folder( - &self, - id: &str, - dto: MoveFolderDto, - caller_id: Uuid, - ) -> Result; - - /// Deletes a folder (ownership verified against caller_id) - async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>; - - /// Creates a root-level home folder for a user during registration. - async fn create_home_folder( - &self, - user_id: Uuid, - name: String, - ) -> Result; - - /// Lists every folder in a subtree rooted at `folder_id` (inclusive), - /// ordered by path. Uses ltree `<@` — single GiST-indexed query. - /// - /// Default: returns an empty vec (stubs / mocks). - async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { - let _ = folder_id; - Ok(Vec::new()) - } -} - /** * Primary port for file and folder search. * diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index d3250c1e..cccc6c8e 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -10,6 +10,7 @@ pub mod dedup_ports; pub mod favorites_ports; pub mod file_lifecycle; pub mod file_ports; +pub mod folder_ports; pub mod inbound; pub mod music_ports; pub mod outbound; diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index aaecf820..01fd29da 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -5,7 +5,7 @@ use crate::application::ports::auth_ports::{ OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort, UserStoragePort, }; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::services::folder_service::FolderService; use crate::common::config::OidcConfig; use crate::common::errors::{DomainError, ErrorKind}; diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 7a12c4f2..7bbaa9ec 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -12,7 +12,7 @@ use tracing::info; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto}; use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase}; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::storage_ports::CopyFolderTreeResult; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::file_management_service::FileManagementService; @@ -145,7 +145,7 @@ impl BatchOperationService { async move { let copy_result = mgmt - .copy_file_owned(&file_id, user_id, target_folder.map(|s| s.to_string())) + .copy_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string())) .await; (file_id, copy_result) } @@ -211,7 +211,7 @@ impl BatchOperationService { async move { let move_result = mgmt - .move_file_owned(&file_id, user_id, target_folder.map(|s| s.to_string())) + .move_file_with_perms(&file_id, user_id, target_folder.map(|s| s.to_string())) .await; (file_id, move_result) } @@ -270,7 +270,7 @@ impl BatchOperationService { let mgmt = self.file_management.clone(); async move { - let delete_result = mgmt.delete_file_owned(&file_id, user_id).await; + let delete_result = mgmt.delete_file_with_perms(&file_id, user_id).await; let id_for_result = file_id.clone(); (file_id, delete_result.map(|_| id_for_result)) } @@ -390,7 +390,9 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); async move { - let delete_result = folder_service.delete_folder(&folder_id, user_id).await; + let delete_result = folder_service + .delete_folder_with_perms(&folder_id, user_id) + .await; let id_for_result = folder_id.clone(); (folder_id, delete_result.map(|_| id_for_result)) } @@ -583,7 +585,9 @@ impl BatchOperationService { let dto = MoveFolderDto { parent_id: target.map(|s| s.to_string()), }; - let move_result = folder_service.move_folder(&folder_id, dto, user_id).await; + let move_result = folder_service + .move_folder_with_perms(&folder_id, dto, user_id) + .await; (folder_id, move_result) } })) @@ -644,7 +648,7 @@ impl BatchOperationService { async move { let copy_result = file_management - .copy_folder_tree_owned( + .copy_folder_tree_with_perms( &folder_id, user_id, target.map(|s| s.to_string()), @@ -732,7 +736,7 @@ impl BatchOperationService { for folder_id in &folder_ids { match self .folder_service - .get_folder_owned(folder_id, user_id) + .get_folder_with_perms(folder_id, user_id) .await { Ok(root_folder) => { @@ -979,7 +983,7 @@ impl BatchOperationService { name: name.clone(), parent_id: parent_id.clone(), }; - let create_result = folder_service.create_folder(dto, user_id).await; + let create_result = folder_service.create_folder_with_perms(dto, user_id).await; let id = format!("{}:{}", name, parent_id.unwrap_or_default()); (id, create_result) } @@ -1039,7 +1043,9 @@ impl BatchOperationService { let folder_service = self.folder_service.clone(); async move { - let get_result = folder_service.get_folder_owned(&folder_id, user_id).await; + let get_result = folder_service + .get_folder_with_perms(&folder_id, user_id) + .await; (folder_id, get_result) } })) diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 77c53795..98c1aad4 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -103,9 +103,8 @@ impl FileManagementService { }; folder_repo.verify_owner(target, caller_id).await } -} -impl FileManagementUseCase for FileManagementService { + //impl FileManagementPrivateUseCase for FileManagementService { async fn move_file( &self, file_id: &str, @@ -135,20 +134,6 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(moved_file)) } - async fn move_file_owned( - &self, - file_id: &str, - caller_id: Uuid, - folder_id: Option, - ) -> Result { - // 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) - .await?; - self.move_file(file_id, folder_id).await - } - async fn copy_file( &self, file_id: &str, @@ -178,18 +163,6 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(copied_file)) } - async fn copy_file_owned( - &self, - file_id: &str, - caller_id: Uuid, - target_folder_id: Option, - ) -> Result { - self.verify_owner(file_id, caller_id).await?; - self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id) - .await?; - self.copy_file(file_id, target_folder_id).await - } - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { if let Err(reason) = validate_storage_name(new_name) { return Err(DomainError::validation_error(format!( @@ -217,65 +190,7 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(renamed_file)) } - async fn rename_file_owned( - &self, - file_id: &str, - caller_id: Uuid, - new_name: &str, - ) -> Result { - self.verify_owner(file_id, caller_id).await?; - self.rename_file(file_id, new_name).await - } - async fn delete_file(&self, id: &str) -> Result<(), DomainError> { - self.file_repository.delete_file(id).await?; - if let Some(cc) = &self.content_cache { - cc.invalidate(id).await; - } - for hook in &self.file_deleted_hooks { - hook.on_file_deleted(id).await; - } - Ok(()) - } - - async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { - self.verify_owner(id, caller_id).await?; - self.delete_file(id).await - } - - /// Smart delete: trash-first with dedup reference cleanup. - /// - /// Blob ref_count bookkeeping is handled entirely by the PG trigger - /// `trg_files_decrement_blob_ref` which fires on DELETE FROM storage.files. - /// We do NOT decrement here — trashing is a soft-delete (UPDATE, not DELETE) - /// so the blob must remain referenced until the file is permanently deleted. - async fn delete_with_cleanup(&self, id: &str, user_id: Uuid) -> Result { - // 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); - match trash.move_to_trash(id, "file", user_id).await { - Ok(_) => { - info!("File successfully moved to trash: {}", id); - // Invalidate content cache — trashed files must not be served. - if let Some(cc) = &self.content_cache { - cc.invalidate(id).await; - } - // Do NOT decrement blob ref here — the file row still exists - // (is_trashed = TRUE). The trigger will decrement when the - // row is actually DELETEd during trash emptying. - return Ok(true); // trashed - } - Err(err) => { - error!("Could not move file to trash: {:?}", err); - warn!("Falling back to permanent delete"); - // fall through - } - } - } else { - warn!("Trash service not available, using permanent delete"); - } - - // Step 2: Permanent delete — trigger handles blob ref_count warn!("Permanently deleting file: {}", id); self.file_repository.delete_file(id).await?; if let Some(cc) = &self.content_cache { @@ -285,8 +200,7 @@ impl FileManagementUseCase for FileManagementService { hook.on_file_deleted(id).await; } info!("File permanently deleted: {}", id); - - Ok(false) // permanently deleted + Ok(()) } async fn copy_folder_tree( @@ -319,8 +233,95 @@ impl FileManagementUseCase for FileManagementService { Ok(result) } +} - async fn copy_folder_tree_owned( +impl FileManagementUseCase for FileManagementService { + async fn move_file_with_perms( + &self, + file_id: &str, + caller_id: Uuid, + folder_id: Option, + ) -> Result { + // 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) + .await?; + self.move_file(file_id, folder_id).await + } + + async fn copy_file_with_perms( + &self, + file_id: &str, + caller_id: Uuid, + target_folder_id: Option, + ) -> Result { + self.verify_owner(file_id, caller_id).await?; + self.verify_target_folder_owner(target_folder_id.as_deref(), caller_id) + .await?; + self.copy_file(file_id, target_folder_id).await + } + + async fn rename_file_with_perms( + &self, + file_id: &str, + caller_id: Uuid, + new_name: &str, + ) -> Result { + self.verify_owner(file_id, 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.delete_file(id).await + } + + /// Smart delete: trash-first with dedup reference cleanup. + /// + /// Blob ref_count bookkeeping is handled entirely by the PG trigger + /// `trg_files_decrement_blob_ref` which fires on DELETE FROM storage.files. + /// We do NOT decrement here — trashing is a soft-delete (UPDATE, not DELETE) + /// so the blob must remain referenced until the file is permanently deleted. + async fn delete_and_cleanup_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result { + self.verify_owner(id, 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); + match trash.move_to_trash(id, "file", caller_id).await { + Ok(_) => { + info!("File successfully moved to trash: {}", id); + // Invalidate content cache — trashed files must not be served. + if let Some(cc) = &self.content_cache { + cc.invalidate(id).await; + } + // Do NOT decrement blob ref here — the file row still exists + // (is_trashed = TRUE). The trigger will decrement when the + // row is actually DELETEd during trash emptying. + return Ok(true); // trashed + } + Err(err) => { + error!("Could not move file to trash: {:?}", err); + warn!("Falling back to permanent delete"); + // fall through + } + } + } else { + warn!("Trash service not available, using permanent delete"); + } + + // Step 2: Permanent delete — trigger handles blob ref_count + + self.delete_file(id).await?; + + Ok(false) // permanently deleted + } + + async fn copy_folder_tree_with_perms( &self, source_folder_id: &str, caller_id: Uuid, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index b8e910b0..7c7ee192 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -1,7 +1,7 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::services::path_service::{StoragePath, validate_storage_name}; @@ -25,7 +25,7 @@ impl FolderService { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { - async fn create_folder( + async fn create_folder_with_perms( &self, _dto: CreateFolderDto, _user_id: Uuid, @@ -37,7 +37,7 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn get_folder_owned( + async fn get_folder_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -101,7 +101,7 @@ impl FolderService { ) } - async fn rename_folder( + async fn rename_folder_with_perms( &self, _id: &str, _dto: RenameFolderDto, @@ -110,7 +110,7 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn move_folder( + async fn move_folder_with_perms( &self, _id: &str, _dto: MoveFolderDto, @@ -119,7 +119,11 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn delete_folder(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { + async fn delete_folder_with_perms( + &self, + _id: &str, + _caller_id: Uuid, + ) -> Result<(), DomainError> { Ok(()) } @@ -138,7 +142,7 @@ impl FolderService { impl FolderUseCase for FolderService { /// Creates a new folder - async fn create_folder( + async fn create_folder_with_perms( &self, dto: CreateFolderDto, caller_id: Uuid, @@ -204,7 +208,11 @@ impl FolderUseCase for FolderService { } /// Gets a folder by its ID, enforcing that `caller_id` is the owner. - async fn get_folder_owned(&self, id: &str, caller_id: Uuid) -> Result { + async fn get_folder_with_perms( + &self, + id: &str, + caller_id: Uuid, + ) -> Result { let folder_dto = self.get_folder(id).await?; if folder_dto.owner_id.as_deref() != Some(&caller_id.to_string()) { tracing::warn!( @@ -261,10 +269,6 @@ impl FolderUseCase for FolderService { parent_id: Option<&str>, owner_id: Uuid, ) -> Result, DomainError> { - let owner_id_short = { - let s = owner_id.to_string(); - s[..8.min(s.len())].to_string() - }; let folders = self .folder_storage .list_folders_by_owner(parent_id, owner_id) @@ -286,6 +290,10 @@ impl FolderUseCase for FolderService { "No root folders found for user {}, creating home folder automatically", owner_id ); + let owner_id_short = { + let s = owner_id.to_string(); + s[..8.min(s.len())].to_string() + }; let folder_name = format!("My Folder - {}", owner_id_short); match self .folder_storage @@ -388,7 +396,7 @@ impl FolderUseCase for FolderService { } /// Renames a folder after verifying ownership. - async fn rename_folder( + async fn rename_folder_with_perms( &self, id: &str, dto: RenameFolderDto, @@ -431,7 +439,7 @@ impl FolderUseCase for FolderService { } /// Moves a folder to a new parent after verifying ownership. - async fn move_folder( + async fn move_folder_with_perms( &self, id: &str, dto: MoveFolderDto, @@ -497,7 +505,7 @@ impl FolderUseCase for FolderService { } /// Deletes a folder after verifying ownership. - async fn delete_folder(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { + 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?; diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 4abe8766..0a36815f 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -362,7 +362,7 @@ async fn stub_move_file_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileManagementUseCase; let result = stub - .move_file_owned("file-1", user_id, Some("folder-2".to_string())) + .move_file_with_perms("file-1", user_id, Some("folder-2".to_string())) .await; assert!(result.is_ok(), "stub should return Ok for move_file_owned"); } @@ -372,7 +372,7 @@ async fn stub_rename_file_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileManagementUseCase; let result = stub - .rename_file_owned("file-1", user_id, "new-name.txt") + .rename_file_with_perms("file-1", user_id, "new-name.txt") .await; assert!( result.is_ok(), diff --git a/src/application/services/share_browse_service.rs b/src/application/services/share_browse_service.rs index a52eceaa..fdd2ecf5 100644 --- a/src/application/services/share_browse_service.rs +++ b/src/application/services/share_browse_service.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; use crate::application::services::share_service::ShareService; diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 43fd0f5f..dd944da2 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -25,7 +25,9 @@ use crate::application::dtos::search_dto::{ use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, OptimizedFileContent, }; -use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::folder_ports::FolderUseCase; + +use crate::application::ports::inbound::SearchUseCase; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::zip_ports::ZipPort; use crate::common::errors::DomainError; @@ -353,7 +355,7 @@ impl I18nService for StubI18nService { pub struct StubFolderUseCase; impl FolderUseCase for StubFolderUseCase { - async fn create_folder( + async fn create_folder_with_perms( &self, _dto: CreateFolderDto, _user_id: Uuid, @@ -365,7 +367,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn get_folder_owned( + async fn get_folder_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -406,7 +408,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) } - async fn rename_folder( + async fn rename_folder_with_perms( &self, _id: &str, _dto: RenameFolderDto, @@ -415,7 +417,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn move_folder( + async fn move_folder_with_perms( &self, _id: &str, _dto: MoveFolderDto, @@ -424,7 +426,11 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn delete_folder(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { + async fn delete_folder_with_perms( + &self, + _id: &str, + _caller_id: Uuid, + ) -> Result<(), DomainError> { Ok(()) } @@ -617,23 +623,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { pub struct StubFileManagementUseCase; impl FileManagementUseCase for StubFileManagementUseCase { - async fn move_file( - &self, - _file_id: &str, - _folder_id: Option, - ) -> Result { - Ok(FileDto::default()) - } - - async fn copy_file( - &self, - _file_id: &str, - _folder_id: Option, - ) -> Result { - Ok(FileDto::default()) - } - - async fn copy_file_owned( + async fn copy_file_with_perms( &self, _file_id: &str, _caller_id: Uuid, @@ -642,23 +632,19 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { - Ok(FileDto::default()) - } - - async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { + async fn delete_file_with_perms(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } - async fn delete_file_owned(&self, _id: &str, _caller_id: Uuid) -> Result<(), DomainError> { - Ok(()) - } - - async fn delete_with_cleanup(&self, _id: &str, _user_id: Uuid) -> Result { + async fn delete_and_cleanup_with_perms( + &self, + _id: &str, + _user_id: Uuid, + ) -> Result { Ok(false) } - async fn move_file_owned( + async fn move_file_with_perms( &self, _file_id: &str, _caller_id: Uuid, @@ -667,7 +653,7 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn rename_file_owned( + async fn rename_file_with_perms( &self, _file_id: &str, _caller_id: Uuid, @@ -676,7 +662,7 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } - async fn copy_folder_tree_owned( + async fn copy_folder_tree_with_perms( &self, _source_folder_id: &str, _caller_id: Uuid, diff --git a/src/infrastructure/services/zip_service.rs b/src/infrastructure/services/zip_service.rs index c92014ee..ed7fff13 100644 --- a/src/infrastructure/services/zip_service.rs +++ b/src/infrastructure/services/zip_service.rs @@ -3,7 +3,7 @@ use crate::application::services::folder_service::FolderService; use crate::{ application::dtos::file_dto::FileDto, application::ports::file_ports::FileRetrievalUseCase, - application::ports::inbound::FolderUseCase, + application::ports::folder_ports::FolderUseCase, application::ports::zip_ports::ZipPort, common::errors::{DomainError, ErrorKind, Result}, }; diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 5821e3af..0286a5b4 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -117,10 +117,10 @@ impl FileHandler { // ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ── if let Some(ref fid) = folder_id { - use crate::application::ports::inbound::FolderUseCase; + use crate::application::ports::folder_ports::FolderUseCase; let folder_service = &state.applications.folder_service; if folder_service - .get_folder_owned(fid, auth_user.id) + .get_folder_with_perms(fid, auth_user.id) .await .is_err() { @@ -875,7 +875,7 @@ impl FileHandler { // Auth required: trash-first with dedup cleanup + ownership verification let result = mgmt - .delete_with_cleanup(&id, auth_user.id) + .delete_and_cleanup_with_perms(&id, auth_user.id) .await .map(|was_trashed| { if was_trashed { @@ -917,7 +917,10 @@ impl FileHandler { tracing::info!("Renaming file {} to \"{}\"", id, new_name); let mgmt = &state.applications.file_management_service; - match mgmt.rename_file_owned(&id, auth_user.id, &new_name).await { + match mgmt + .rename_file_with_perms(&id, auth_user.id, &new_name) + .await + { Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -935,7 +938,7 @@ impl FileHandler { let mgmt = &state.applications.file_management_service; match mgmt - .move_file_owned(&id, auth_user.id, payload.folder_id) + .move_file_with_perms(&id, auth_user.id, payload.folder_id) .await { Ok(file) => (StatusCode::OK, Json(file)).into_response(), @@ -956,7 +959,10 @@ impl FileHandler { .map(|s| s.to_string()); let mgmt = &state.applications.file_management_service; - match mgmt.move_file_owned(&id, auth_user.id, folder_id).await { + match mgmt + .move_file_with_perms(&id, auth_user.id, folder_id) + .await + { Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), Err(err) => AppError::from(err).into_response(), } diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 45cc0276..bf757ce3 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -16,7 +16,7 @@ use crate::application::dtos::folder_dto::{ use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; @@ -76,7 +76,7 @@ impl FolderHandler { } } - match service.create_folder(dto, auth_user.id).await { + match service.create_folder_with_perms(dto, auth_user.id).await { Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -244,7 +244,10 @@ impl FolderHandler { Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { - match service.rename_folder(&id, dto, auth_user.id).await { + match service + .rename_folder_with_perms(&id, dto, auth_user.id) + .await + { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -257,7 +260,7 @@ impl FolderHandler { Path(id): Path, Json(dto): Json, ) -> impl IntoResponse { - match service.move_folder(&id, dto, auth_user.id).await { + match service.move_folder_with_perms(&id, dto, auth_user.id).await { Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } @@ -269,7 +272,7 @@ impl FolderHandler { auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - match service.delete_folder(&id, auth_user.id).await { + match service.delete_folder_with_perms(&id, auth_user.id).await { Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(err) => AppError::from(err).into_response(), } @@ -304,7 +307,7 @@ impl FolderHandler { // Fallback to permanent delete if trash is unavailable or failed let folder_service = &state.applications.folder_service; - match folder_service.delete_folder(&id, user_id).await { + match folder_service.delete_folder_with_perms(&id, user_id).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 2a2f58b3..5609675d 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -22,7 +22,7 @@ use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUseCase}; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::storage_ports::StorageUsagePort; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; @@ -1046,7 +1046,7 @@ async fn handle_mkcol( // their proper HTTP status codes (was: blanket 500 swallowed // ownership-rejection NotFound from verify_owner). let created = folder_service - .create_folder(create_dto, user.id) + .create_folder_with_perms(create_dto, user.id) .await .map_err(AppError::from)?; parent_id = Some(created.id); @@ -1092,7 +1092,7 @@ async fn handle_delete( match resolver.resolve_path_for_user(&path, user.id).await { Ok(ResolvedResource::Folder(folder)) => { folder_service - .delete_folder(&folder.id, user.id) + .delete_folder_with_perms(&folder.id, user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to delete folder: {}", e)) @@ -1100,7 +1100,7 @@ async fn handle_delete( } Ok(ResolvedResource::File(file)) => { file_management_service - .delete_file(&file.id) + .delete_file_with_perms(&file.id, user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to delete file: {}", e)) @@ -1115,7 +1115,7 @@ async fn handle_delete( if let Ok(folder) = folder_result { assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?; folder_service - .delete_folder(&folder.id, user.id) + .delete_folder_with_perms(&folder.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; } else { @@ -1126,7 +1126,7 @@ async fn handle_delete( assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; file_management_service - .delete_file(&file.id) + .delete_file_with_perms(&file.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; } @@ -1248,7 +1248,7 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto, user.id) + .move_folder_with_perms(&folder.id, move_dto, user.id) .await .map_err(AppError::from)?; @@ -1257,7 +1257,7 @@ async fn handle_move( name: dest_folder_name.to_string(), }; folder_service - .rename_folder(&folder.id, rename_dto, user.id) + .rename_folder_with_perms(&folder.id, rename_dto, user.id) .await .map_err(AppError::from)?; } @@ -1291,13 +1291,13 @@ async fn handle_move( )?; } file_management_service - .move_file(&file.id, Some(dest_parent_path.to_string())) + .move_file_with_perms(&file.id, user.id, Some(dest_parent_path.to_string())) .await .map_err(AppError::from)?; } if file.name != dest_filename { file_management_service - .rename_file(&file.id, dest_filename) + .rename_file_with_perms(&file.id, user.id, dest_filename) .await .map_err(AppError::from)?; } @@ -1349,7 +1349,7 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto, user.id) + .move_folder_with_perms(&folder.id, move_dto, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; @@ -1358,7 +1358,7 @@ async fn handle_move( name: dest_folder_name.to_string(), }; folder_service - .rename_folder(&folder.id, rename_dto, user.id) + .rename_folder_with_perms(&folder.id, rename_dto, user.id) .await .map_err(AppError::from)?; } @@ -1398,13 +1398,13 @@ async fn handle_move( )?; } file_management_service - .move_file(&file.id, Some(dest_parent_path.to_string())) + .move_file_with_perms(&file.id, user.id, Some(dest_parent_path.to_string())) .await .map_err(AppError::from)?; } if file.name != dest_filename { file_management_service - .rename_file(&file.id, dest_filename) + .rename_file_with_perms(&file.id, user.id, dest_filename) .await .map_err(AppError::from)?; } @@ -1535,8 +1535,9 @@ async fn handle_copy( if recursive { let file_management_service = &state.applications.file_management_service; file_management_service - .copy_folder_tree( + .copy_folder_tree_with_perms( &folder.id, + user.id, target_parent_id, Some(dest_folder_name.to_string()), ) @@ -1550,7 +1551,7 @@ async fn handle_copy( parent_id: target_parent_id, }; folder_service - .create_folder(create_dto, user.id) + .create_folder_with_perms(create_dto, user.id) .await .map_err(|e| { AppError::internal_error(format!( @@ -1586,7 +1587,7 @@ async fn handle_copy( let file_management_service = &state.applications.file_management_service; file_management_service - .copy_file(&file.id, target_folder_id) + .copy_file_with_perms(&file.id, user.id, target_folder_id) .await .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; } @@ -1639,8 +1640,9 @@ async fn handle_copy( if recursive { let file_management_service = &state.applications.file_management_service; file_management_service - .copy_folder_tree( + .copy_folder_tree_with_perms( &folder.id, + user.id, target_parent_id, Some(dest_folder_name.to_string()), ) @@ -1654,7 +1656,7 @@ async fn handle_copy( parent_id: target_parent_id, }; folder_service - .create_folder(create_dto, user.id) + .create_folder_with_perms(create_dto, user.id) .await .map_err(|e| { AppError::internal_error(format!( @@ -1697,7 +1699,7 @@ async fn handle_copy( let file_management_service = &state.applications.file_management_service; file_management_service - .copy_file(&file.id, target_folder_id) + .copy_file_with_perms(&file.id, user.id, target_folder_id) .await .map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?; } diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 525f9f80..03311557 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -18,7 +18,8 @@ use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::folder_ports::FolderUseCase; +use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index dd133112..a4a1cdb4 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -17,7 +17,7 @@ use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; -use crate::application::ports::inbound::FolderUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::common::mime_detect::{filename_from_path, refine_content_type}; @@ -656,7 +656,7 @@ async fn handle_mkcol( name: segment.to_string(), parent_id: Some(parent_id.clone()), }; - match folder_service.create_folder(dto, user.id).await { + match folder_service.create_folder_with_perms(dto, user.id).await { Ok(created) => { parent_id = created.id.clone(); } @@ -732,7 +732,7 @@ async fn handle_delete( if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await { folder_service - .delete_folder(&folder.id, user.id) + .delete_folder_with_perms(&folder.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; @@ -744,7 +744,7 @@ async fn handle_delete( if let Ok(file) = file_service.get_file_by_path(&internal_path).await { file_mgmt - .delete_file(&file.id) + .delete_file_with_perms(&file.id, user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; @@ -798,7 +798,7 @@ async fn handle_move( if src_parent_sub == dest_parent_sub { // Same parent → rename. file_mgmt - .rename_file(&file.id, dest_name) + .rename_file_with_perms(&file.id, user.id, dest_name) .await .map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?; } else { @@ -809,14 +809,14 @@ async fn handle_move( .map_err(|_| AppError::not_found("Destination folder not found"))?; file_mgmt - .move_file(&file.id, Some(dest_parent.id.clone())) + .move_file_with_perms(&file.id, user.id, Some(dest_parent.id.clone())) .await .map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?; // If the filename changed too, rename after move. if file.name != dest_name { file_mgmt - .rename_file(&file.id, dest_name) + .rename_file_with_perms(&file.id, user.id, dest_name) .await .map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?; } @@ -851,7 +851,7 @@ async fn handle_move( // Same parent → rename. use crate::application::dtos::folder_dto::RenameFolderDto; folder_service - .rename_folder( + .rename_folder_with_perms( &folder.id, RenameFolderDto { name: dest_name.to_string(), @@ -869,7 +869,7 @@ async fn handle_move( use crate::application::dtos::folder_dto::MoveFolderDto; folder_service - .move_folder( + .move_folder_with_perms( &folder.id, MoveFolderDto { parent_id: Some(dest_parent.id.clone()), @@ -883,7 +883,7 @@ async fn handle_move( if folder.name != dest_name { use crate::application::dtos::folder_dto::RenameFolderDto; folder_service - .rename_folder( + .rename_folder_with_perms( &folder.id, RenameFolderDto { name: dest_name.to_string(), From 2c53f9908981a66fdc466690c9fa02555bc35035 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 20 May 2026 18:54:02 +0200 Subject: [PATCH 04/13] ai: save ReBAC Permission, Grants, Cascading plan --- docs/plan/README.md | 1 + ...plan-ReBAC-Permissions-Grants-Cascading.md | 982 ++++++++++++++++++ 2 files changed, 983 insertions(+) create mode 100644 docs/plan/README.md create mode 100644 docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md diff --git a/docs/plan/README.md b/docs/plan/README.md new file mode 100644 index 00000000..af0b3ad4 --- /dev/null +++ b/docs/plan/README.md @@ -0,0 +1 @@ +# This directory contains plans and implementation architectures diff --git a/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md b/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md new file mode 100644 index 00000000..5a0a6506 --- /dev/null +++ b/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md @@ -0,0 +1,982 @@ +# OxiCloud ReBAC — Permissions, Grants, and Cascading + +## Context + +OxiCloud currently has a binary authorization model: the owner of a folder/file has every permission, every non-owner has none. The only user-to-user sharing is via anonymous token links (`storage.shares`) with three coarse flags (read/write/reshare). There is no way for a user to grant a named user fine-grained access to a folder, and no way to list resources others have shared with them. + +This plan introduces a Relationship-Based Access Control (ReBAC) model: + +- 6 named permissions: `read`, `create`, `share`, `comment`, `delete`, `update` +- Cascading: a grant on a folder applies to all descendants (sub-folders + files) via the existing `storage.folders.lpath` ltree +- Subjects: `user` (v1), `group` (future placeholder in schema), `token` (anonymous links — unified with existing `storage.shares`), `external` (future in schema for federated identities: Open Cloud Mesh / external OIDC) +- Pluggable engine: a single `AuthorizationEngine` trait, default implementation in PostgreSQL, ready for an `OpenFgaEngine` later +- **Roles** (Viewer, Commenter, Editor, Manager, Admin) as a UX/DTO sugar layer that the server expands into the underlying permission rows — storage and engine know nothing about roles + +User decisions confirmed in conversation: +1. **Implicit owner** — owners have no rows in `access_grants`; the engine short-circuits when the caller is the resource's owner. +2. **`share` permission lets the holder grant to other named users** via `POST /api/grants` (not just create anonymous links). +3. **`GET /api/grants/incoming` returns direct grants only** — one row per resource explicitly granted to the caller. UI drills in via existing listing endpoints. +4. **Unify anonymous link shares under `access_grants`** with `subject_type='token'`. `storage.shares` retains token-lifecycle metadata (password, expiry, access count) only; the permission flags move to `access_grants`. One-time data migration. +5. **Roles in v1, implication chains deferred** — roles bundle the 6 raw permissions at the DTO layer (no schema impact). The storage keeps one row per granted permission. Permission implication (e.g., `update` ⊃ `comment` ⊃ `read`) is a Future optimization that compresses storage but doesn't change observable behavior. +6. **6 permissions are final for v1** — `read`, `create`, `share`, `comment`, `delete`, `update`. `download` (preview-only vs full-bytes) is a candidate for v2 if a "view-only" feature is added; trivial ALTER on the CHECK constraint then. +7. **Schema reserves `subject_type='external'` for federated identities** (Open Cloud Mesh / external OIDC). v1 adds the enum value and the `Subject::External(Uuid)` variant; the lookup table `auth.external_subjects` and the federation middleware are deferred. +8. **Architectural rule: AuthZ lives in the service layer, never in handlers.** All permission checks go through `AuthorizationEngine` via service methods. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they do NOT perform their own ownership/permission checks. This rule must be documented in `CLAUDE.md`. +9. **Per-row storage, not bitmap.** One row per `(subject, resource, permission)` rather than a single row with a packed bitmap. Preserves per-permission `granted_at` and `granted_by` (audit value), keeps future per-grant `expires_at` an easy addition, and maps 1:1 to OpenFGA tuples. Storage cost at OxiCloud's scale is acceptable and not on a hot path — micro-optimization deferred indefinitely. Matches the per-tuple shape used by Zanzibar, SpiceDB, OpenFGA, Permify. + +Out-of-scope (deferred): +- Group creation & membership UI (the schema reserves `subject_type='group'`, but no group CRUD endpoints in this plan) +- External-user federation (`subject_type='external'` reserved in schema; `auth.external_subjects` table + OCM/OIDC federation middleware come later) +- Permission implication graph (`update` ⊃ `comment` ⊃ `read`, etc.) — storage compression with no observable behavior change +- Negative grants / deny rules (model stays additive — union of all applicable grants) +- Grant expiry per-row (token expiry stays on `storage.shares`) +- Comment feature itself (the `comment` permission is reserved; the comments table is a future feature) +- `download` permission (separation of preview-only from full-bytes export) +- Decision caching (in-process + Redis L2) — see "Future: caching layer" below + +--- + +## Architecture overview + +``` +┌────────────────────┐ +│ HTTP handlers │ POST/GET/DELETE /api/grants +└──────────┬─────────┘ + ▼ +┌────────────────────┐ +│ FolderService │ ────► authz.require(caller, Update, Folder(id)) +│ FileManagementSvc │ ────► authz.require(caller, Create, Folder(parent)) +│ FileRetrievalSvc │ ────► authz.require(caller, Read, Folder(id)) +│ ShareService │ ────► token grants written via authz.grant(Token(t), ...) +└──────────┬─────────┘ + ▼ Arc +┌─────────────────────────────────────────────┐ +│ AuthorizationEngine trait │ +│ • check(subject, perm, resource) → bool │ +│ • require(...) │ +│ • grant / revoke │ +│ • list_incoming / list_on_resource │ +└──────────┬────────────────────────┬─────────┘ + ▼ ▼ + PgAclEngine (v1, default) OpenFgaEngine (future) + ▼ + storage.access_grants + storage.folders.lpath (cascading) +``` + +--- + +## Schema + +### New table: `storage.access_grants` + +```sql +CREATE TABLE storage.access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Subject (who has the permission) + -- 'user' — auth.users.id + -- 'group' — future: group membership + -- 'token' — refers to storage.shares.id (anonymous link) + -- 'external' — future: refers to auth.external_subjects.id (OCM / federated OIDC) + subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group', 'token', 'external')), + subject_id UUID NOT NULL, + + -- Resource (what the permission is on) + resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')), + resource_id UUID NOT NULL, + + -- Permission (what action is allowed) + permission TEXT NOT NULL CHECK (permission IN + ('read', 'create', 'share', 'comment', 'delete', 'update')), + + -- Audit + granted_by UUID NOT NULL, -- user_id who created the grant + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (subject_type, subject_id, resource_type, resource_id, permission) +); + +CREATE INDEX idx_grants_subject ON storage.access_grants (subject_type, subject_id); +CREATE INDEX idx_grants_resource ON storage.access_grants (resource_type, resource_id); +``` + +`granted_by` is always a user (group cannot grant). No FK to `auth.users` on `subject_id` or `granted_by` — those tables are in a different schema and the values are polymorphic. + +### Cleanup of `storage.shares` + +The permission columns move to `access_grants`. `storage.shares` keeps token-lifecycle metadata. + +```sql +-- After data migration (below): +ALTER TABLE storage.shares + DROP COLUMN permissions_read, + DROP COLUMN permissions_write, + DROP COLUMN permissions_reshare; +``` + +### Data migration (one-off, in the same migration file) + +Each existing share becomes one or more rows in `access_grants` with `subject_type='token'`, `subject_id=shares.id`: + +```sql +INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by) +SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by + FROM storage.shares s + WHERE s.permissions_read; + +-- 'write' on the old model implies full mutation rights for the link holder. +-- Mapped to read + create + update + delete in the new model. +INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by) +SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by + FROM storage.shares s + CROSS JOIN (VALUES ('create'), ('update'), ('delete')) AS p(perm) + WHERE s.permissions_write; + +INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by) +SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by + FROM storage.shares s + WHERE s.permissions_reshare; +``` + +--- + +## Lifecycle and grant cleanup (v1 — correctness requirement) + +When a resource or subject is **permanently** deleted, all `access_grants` rows referring to it must be removed. Otherwise: +- Orphan grants linger forever +- A future UUID reuse (unlikely but possible) could match a stale row +- "Shared with me" returns grants on resources that no longer exist +- Audit queries (`COUNT(*) FROM access_grants`) drift away from reality + +### What triggers cleanup, and what doesn't + +| Event | Affected grants | Action | +|---|---|---| +| Folder **permanently** deleted | `resource_type='folder', resource_id=F` (plus all descendant files via FK cascade chain) | DELETE | +| File **permanently** deleted | `resource_type='file', resource_id=X` | DELETE | +| Folder/file moved to **trash** (soft) | None | **No-op** — restore must resume access | +| Folder/file **restored** from trash | None | No-op | +| Trash **emptied** (permanent destruction) | Same as permanent delete | DELETE | +| User deleted | `subject_type='user', subject_id=U`. `granted_by=U` is left as-is (audit trail). | DELETE the subject rows; keep granter UUIDs | +| Anonymous share token deleted | `subject_type='token', subject_id=T` | DELETE | +| Group deleted (future) | `subject_type='group', subject_id=G` | DELETE | + +### Defense-in-depth: DB triggers in the same migration + +Even if a future code path bypasses the service layer (admin scripts, bulk maintenance, manual SQL), the database enforces cleanup: + +```sql +CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM storage.access_grants + WHERE resource_type = TG_ARGV[0] + AND resource_id = OLD.id; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_cleanup_grants_folder + AFTER DELETE ON storage.folders + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder'); + +CREATE TRIGGER trg_cleanup_grants_file + AFTER DELETE ON storage.files + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file'); + +CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM storage.access_grants + WHERE subject_type = TG_ARGV[0] + AND subject_id = OLD.id; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_cleanup_grants_user + AFTER DELETE ON auth.users + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user'); + +CREATE TRIGGER trg_cleanup_grants_token + AFTER DELETE ON storage.shares + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token'); +``` + +`storage.files.folder_id REFERENCES storage.folders(id) ON DELETE CASCADE` already exists — when a folder is permanently deleted, the file trigger fires for each cascaded child. No need to walk the ltree subtree manually. + +### Application-layer cleanup (explicit hooks) + +The trait gains two cleanup methods so the application layer can invoke cleanup explicitly. This matters because a future cache layer (see Future section) needs to see the invalidation event at the engine boundary — DB triggers happen below the cache: + +```rust +/// Removes all grants targeting this resource. Returns count removed. +async fn revoke_all_for_resource(&self, resource: Resource) + -> Result; + +/// Removes all grants where this subject is the holder. +async fn revoke_all_for_subject(&self, subject: Subject) + -> Result; +``` + +Service call sites: + +| Service method | Cleanup call | +|---|---| +| `FolderService::delete_folder_with_perms` (permanent delete) | `authz.revoke_all_for_resource(Folder(id))` | +| `FileManagementService::delete_file_with_perms` | `authz.revoke_all_for_resource(File(id))` | +| `FileManagementService::delete_and_cleanup_with_perms` | Same | +| `TrashService::delete_permanently` | `authz.revoke_all_for_resource(...)` per item | +| `TrashService::empty_trash` | Loop over items, same call | +| `TrashService::move_to_trash` | **No cleanup** (soft delete; grants preserved for eventual restore) | +| `TrashService::restore_item` | No action (grants are still there) | +| `ShareService::delete_shared_link` | `authz.revoke_all_for_subject(Token(share_id))` | +| `AuthApplicationService::delete_user` (admin) | `authz.revoke_all_for_subject(User(user_id))` | + +DB triggers stay as defense-in-depth — they catch anything the application forgets, and they catch bulk maintenance operations. The application-layer hook is the canonical path; the trigger is the safety net. + +### File lifecycle hook integration + +There's an existing `FileDeletedHook` trait in `src/application/ports/file_lifecycle.rs` that already fires after permanent file deletion (used today for blob-ref-count decrement). Implement an additional hook: + +```rust +struct GrantCleanupHook { authz: Arc } + +#[async_trait::async_trait] +impl FileDeletedHook for GrantCleanupHook { + async fn on_file_deleted(&self, file_id: Uuid) -> Result<(), DomainError> { + self.authz.revoke_all_for_resource(Resource::File(file_id)).await?; + Ok(()) + } +} +``` + +Register it in `common/di.rs` alongside the existing hooks. The folder/user/token cases get inline calls in their respective services (no hook trait for those yet — adding one if it's needed for a third caller is a future refactor). + +### Verification — lifecycle scenarios in `grants.hurl` + +1. **Resource delete clears grants** + - Alice creates folder F, grants Bob read, then permanently deletes F (via empty trash) + - Bob's `GET /api/grants/incoming` returns 0 entries containing F + - Direct SQL check (in a debug endpoint or via a test fixture): `SELECT COUNT(*) FROM access_grants WHERE resource_id = F` is 0 + +2. **Trash retains grants** + - Alice grants Bob read on F, moves F to trash, then restores F + - Bob still has `read` access after restore (regression: before any lifecycle change, this must continue to work) + +3. **User delete clears subject grants but preserves granter** + - Alice grants Bob and Carol read on F. Admin deletes Bob. + - Carol's grant on F survives; her `granted_by=alice` still references Alice (intact) + - Bob's row is gone + +4. **Token delete clears token grants** + - Alice creates a public share link on F → `access_grants` has rows with `subject_type='token'` + - Alice deletes the share link → token rows in `access_grants` are gone + +5. **Orphan invariant (post-test SQL)** + ```sql + SELECT COUNT(*) FROM storage.access_grants g + WHERE (g.resource_type = 'folder' + AND NOT EXISTS (SELECT 1 FROM storage.folders WHERE id = g.resource_id)) + OR (g.resource_type = 'file' + AND NOT EXISTS (SELECT 1 FROM storage.files WHERE id = g.resource_id)); + ``` + Must always be 0 after every Hurl run. + +--- + +## Domain types + +New module `src/domain/services/authorization.rs`: + +```rust +use uuid::Uuid; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Subject { + User(Uuid), + Group(Uuid), // schema reserved, no CRUD endpoints in v1 + Token(Uuid), // refers to storage.shares.id + External(Uuid), // future: refers to auth.external_subjects.id (OCM / federated OIDC) +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Resource { + Folder(Uuid), + File(Uuid), +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Permission { + Read, Create, Share, Comment, Delete, Update, +} + +pub struct Grant { + pub id: Uuid, + pub subject: Subject, + pub resource: Resource, + pub permission: Permission, + pub granted_by: Uuid, + pub granted_at: chrono::DateTime, +} +``` + +Conversion helpers (`as_str()` for SQL binding, `TryFrom<&str>` for row decoding) live alongside. + +--- + +## Port: `AuthorizationEngine` + +New file `src/application/ports/authorization_ports.rs`: + +```rust +use crate::common::errors::DomainError; +use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; + +#[async_trait::async_trait] +pub trait AuthorizationEngine: Send + Sync + 'static { + /// Returns true if `subject` has `permission` on `resource`, + /// considering owner short-circuit AND cascading from folder ancestors. + async fn check( + &self, + subject: Subject, + permission: Permission, + resource: Resource, + ) -> Result; + + /// Convenience: returns Ok(()) when check passes; DomainError::not_found + /// otherwise (anti-enumeration — same error for "no such resource" and + /// "exists but you can't see it"). + 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, + ) -> Result, DomainError>; + + /// All grants on a specific resource (for "Manage sharing" UI). + /// Caller-side must verify the caller has `share` on the resource. + async fn list_grants_on_resource( + &self, + resource: Resource, + ) -> Result, DomainError>; + + /// Idempotent (UNIQUE constraint absorbs duplicates). + async fn grant( + &self, + granted_by: Uuid, + subject: Subject, + permission: Permission, + resource: Resource, + ) -> Result; + + /// Revoke by id. + async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>; +} +``` + +Wired into `AppState` in `src/common/di.rs` as `pub authorization: Arc`. The factory selects the implementation from `OXICLOUD_AUTHZ_ENGINE` env var (default: `postgres`). + +--- + +## PgAclEngine implementation + +New file `src/infrastructure/services/pg_acl_engine.rs`. Holds `Arc`, `Arc`, `Arc` (for owner lookups). + +### `check()` algorithm + +```rust +async fn check(&self, subject: Subject, perm: Permission, resource: Resource) -> Result { + // Step 1 — owner short-circuit (only for user subjects) + if let Subject::User(uid) = subject { + let owner = match resource { + Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await?, + Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await?, + }; + if owner == uid { return Ok(true); } + } + + // Step 2 — direct or cascading grant via SQL + self.grant_exists(subject, perm, resource).await +} +``` + +### Cascading SQL — folders + +```sql +SELECT EXISTS ( + SELECT 1 + FROM storage.access_grants g + JOIN storage.folders gf ON gf.id = g.resource_id + WHERE g.subject_type = $1 AND g.subject_id = $2 + AND g.permission = $3 + AND g.resource_type = 'folder' + AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4) +) +``` + +`gf.lpath @> target.lpath` means "gf is an ancestor of (or equal to) target". Uses the existing GiST index `idx_folders_lpath` — O(log N). + +### Cascading SQL — files + +A file inherits from its containing folder. Two-branch query: + +```sql +SELECT EXISTS ( + -- direct file grant + SELECT 1 FROM storage.access_grants + WHERE subject_type = $1 AND subject_id = $2 AND permission = $3 + AND resource_type = 'file' AND resource_id = $4 + UNION ALL + -- cascading from any ancestor folder of the file's containing folder + SELECT 1 + FROM storage.access_grants g + JOIN storage.folders gf ON gf.id = g.resource_id + JOIN storage.files target_f ON target_f.id = $4 + WHERE g.subject_type = $1 AND g.subject_id = $2 + AND g.permission = $3 + AND g.resource_type = 'folder' + AND target_f.folder_id IS NOT NULL + AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = target_f.folder_id) +) +``` + +Files at root (`folder_id IS NULL`) only match the direct branch. + +### Engine selection in `AppState` + +```rust +// src/common/di.rs (build_app_state) +let authz: Arc = match env::var("OXICLOUD_AUTHZ_ENGINE").as_deref() { + Ok("openfga") => unimplemented!("OpenFgaEngine — future"), + _ => Arc::new(PgAclEngine::new( + pools.clone(), + repositories.folder_repository.clone(), + repositories.file_read_repository.clone(), + )), +}; +``` + +--- + +## Service integration + +Each `*_with_perms` method already calls `verify_owner`. Replace the call with `authz.require(...)`. The semantics broaden (grants count, not just ownership) but the signature and error mapping stay the same. + +### Folder permission mapping (folder_service.rs) + +| Method | Permission(s) checked | +|---|---| +| `create_folder_with_perms(dto, caller)` | `Create` on `Folder(parent_id)` | +| `get_folder_with_perms(id, caller)` | `Read` on `Folder(id)` | +| `rename_folder_with_perms(id, dto, caller)` | `Update` on `Folder(id)` | +| `move_folder_with_perms(id, dto, caller)` | `Update` on `Folder(id)` AND `Create` on `Folder(new_parent)` | +| `delete_folder_with_perms(id, caller)` | `Delete` on `Folder(id)` | + +### File permission mapping (file_management_service.rs) + +| Method | Permission(s) checked | +|---|---| +| `move_file_with_perms(file_id, caller, target)` | `Update` on `File(file_id)` AND `Create` on `Folder(target)` if target is Some | +| `copy_file_with_perms(file_id, caller, target)` | `Read` on `File(file_id)` AND `Create` on `Folder(target)` if target is Some | +| `rename_file_with_perms(file_id, caller, name)` | `Update` on `File(file_id)` | +| `delete_file_with_perms(id, caller)` | `Delete` on `File(id)` | +| `copy_folder_tree_with_perms(src, caller, target, name)` | `Read` on `Folder(src)` AND `Create` on `Folder(target)` if target is Some | + +### File retrieval mapping (file_retrieval_service.rs) + +`get_file_owned`, `list_files_owned`, `get_file_stream_owned`, `get_file_optimized_owned`, `get_file_range_stream_owned`, `list_files_batch_for_owner` → each becomes `authz.require(caller, Read, File(id))` before delegating to the unchecked variant. + +### Path-based lookups (currently unchecked IDOR risk) + +`folder_service::get_folder_by_path(path)` and `file_retrieval_service::get_file_by_path(path)` resolve a path then return the resource without any check. After this plan: resolve, then `authz.require(caller, Read, …)`. This closes a known IDOR documented in the previous plan's "Out of scope" section. + +### Owner short-circuit ensures zero behavior change for current users + +Because every existing user-vs-own-resource interaction is an owner check, the engine's owner short-circuit makes those calls equivalent to the current `verify_owner`. No grant lookups on the hot path until a real cross-user grant exists. + +--- + +## REST endpoints + +New handler `src/interfaces/api/handlers/grant_handler.rs`. Registered under `/api/grants`. + +### `POST /api/grants` — create a grant + +```json +{ + "subject": { "type": "user", "id": "" }, + "resource": { "type": "folder", "id": "" }, + "permissions": ["read", "comment"] +} +``` + +Behavior: +1. Authenticated caller required. +2. `authz.require(caller, Share, resource)` — caller must have `share` on the resource (owners always pass via short-circuit). +3. For each permission in the list: `authz.grant(caller_id, subject, perm, resource)`. UNIQUE constraint makes repeats no-ops. +4. Returns 201 with the list of created/existing grants. + +### `DELETE /api/grants/{id}` — revoke a grant + +1. Look up the grant. +2. Allow if caller is the grant's `granted_by` user OR caller has `share` on the underlying resource. +3. `authz.revoke(id)`. +4. Returns 204. + +### `GET /api/grants/incoming?permission=read&type=folder` — what others have shared with me + +Direct grants only (per user decision). Subject is the authenticated caller's `User(id)`. Optional filters by permission and resource type. + +Returns: +```json +[ + { + "id": "", + "resource": { "type": "folder", "id": "", "name": "Photos", "path": "..." }, + "permission": "read", + "granted_by": { "id": "", "username": "alice" }, + "granted_at": "2026-05-20T10:51:13Z" + } +] +``` + +Resource name/path is enriched via a JOIN to `storage.folders` / `storage.files`. + +### `GET /api/grants?resource_type=folder&resource_id={id}` — list grants on a resource + +Requires `authz.require(caller, Share, resource)` (you can see who has access only if you can manage sharing). + +Returns the same shape as incoming, but for the specified resource. + +### `GET /api/grants/outgoing` — grants I have created + +Filtered by `granted_by = caller_id`. Useful for "Manage all my shares" UI. + +--- + +## Roles (UX / DTO layer) + +Roles are **preset bundles of permissions** that the API exposes for UI convenience. The server expands a role into its underlying permission list before writing rows; storage and engine know nothing about roles. + +### Role catalog + +| Role | Permissions | +|---|---| +| `Viewer` | `read` | +| `Commenter` | `read`, `comment` | +| `Editor` | `read`, `comment`, `create`, `update` | +| `Manager` | `read`, `comment`, `create`, `update`, `share` | +| `Admin` | `read`, `comment`, `create`, `update`, `share`, `delete` | + +Defined as a Rust enum in `src/application/dtos/grant_dto.rs`: + +```rust +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { Viewer, Commenter, Editor, Manager, Admin } + +impl Role { + 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], + } + } +} +``` + +### `POST /api/grants` accepts either shape + +```json +// Either explicit permissions: +{ "subject": { "type": "user", "id": "" }, + "resource": { "type": "folder", "id": "" }, + "permissions": ["read", "comment"] } + +// Or a role: +{ "subject": { "type": "user", "id": "" }, + "resource": { "type": "folder", "id": "" }, + "role": "editor" } +``` + +The DTO uses `#[serde(untagged)]` or two separate fields with server-side validation that exactly one is provided. Server expands `role` → permission list, then writes the rows. + +### `PUT /api/grants/role` — reconcile a subject's role on a resource + +```json +{ "subject": { "type": "user", "id": "" }, + "resource": { "type": "folder", "id": "" }, + "role": "manager" } +``` + +Behavior: +1. `authz.require(caller, Share, resource)`. +2. Read the current set of permissions held by `subject` on `resource`. +3. Compute the diff vs `role.expand()`: which permissions to INSERT, which to DELETE. +4. Apply both in one transaction. +5. Returns 200 with the new full set. + +This is the canonical way for a UI to set "Bob is now Editor of /Photos" — the frontend doesn't track which specific rows exist. + +### Why roles are pure DTO sugar (not stored) + +- **Roles can evolve without schema migrations** — adding "Reviewer" tomorrow is a code change, no ALTER. +- **Mixing is allowed** — a future UI can start from "Editor" and add `share` manually; the result is a custom mixture, not "Editor + share". +- **OpenFGA migration unaffected** — tuples are per-permission regardless of how they were granted. +- **Revocation is granular** — removing a single permission doesn't require touching a "role" abstraction. + +--- + +## File changes + +### New files +- `migrations/2026MMDDHHMMSS_rebac_access_grants.sql` — table + indexes + data migration from `storage.shares` +- `src/domain/services/authorization.rs` — `Subject` (including `External` variant), `Resource`, `Permission`, `Grant` enums/structs +- `src/application/ports/authorization_ports.rs` — `AuthorizationEngine` trait +- `src/infrastructure/services/pg_acl_engine.rs` — default impl +- `src/interfaces/api/handlers/grant_handler.rs` — REST endpoints (`POST/DELETE/GET /api/grants`, `PUT /api/grants/role`, `GET /api/grants/incoming|outgoing`) +- `src/application/dtos/grant_dto.rs` — request/response DTOs including `Role` enum + `Role::expand()` + +### Modified +- `CLAUDE.md` — add a section under the Backend Architecture documenting the rule: **AuthZ is enforced exclusively in the application service layer. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) only authenticate the caller and pass `caller_id` to the service. Never duplicate permission checks at the exposition layer.** This prevents drift between layers and matches the existing pattern of `*_with_perms` methods. +- `src/common/di.rs` — wire `authz` into `AppState`; inject into Folder/FileManagement/FileRetrieval services +- `src/application/services/folder_service.rs` — replace `verify_owner` calls with `authz.require`; add path-based check to `get_folder_by_path` +- `src/application/services/file_management_service.rs` — same; remove the private `verify_target_folder_owner` wrapper (engine does both) +- `src/application/services/file_retrieval_service.rs` — replace owner checks; add path-based check to `get_file_by_path` +- `src/application/services/share_service.rs` — on `create_shared_link`, also write the corresponding `access_grants` rows so that token-based access goes through the engine uniformly +- `src/interfaces/api/routes.rs` — register `/api/grants` routes +- `src/application/ports/mod.rs` — `pub mod authorization_ports` +- `src/domain/services/mod.rs` — `pub mod authorization` +- `src/infrastructure/services/mod.rs` — `pub mod pg_acl_engine` + +### Removed +- The fields `permissions_read`, `permissions_write`, `permissions_reshare` from `storage.shares` (and their domain/dto representations) — replaced by `access_grants` rows. Migration script preserves existing data. + +--- + +## Verification + +### Build & lint +``` +cargo fmt --all +cargo clippy --all-features --all-targets -- -D warnings +cargo test --workspace +``` + +### Hurl integration tests (new file `tests/api/grants.hurl`) + +Run via the existing `tests/api/run.sh` (add `permissions.hurl` AND the new `grants.hurl` to the runner). + +Setup (admin token + bob token, both already available from `permissions.hurl`): + +1. **Grant + check** + - Alice creates folder `/api/folders {parent: home, name: "Shared"}` → captures `folder_id` + - Alice grants Bob `read` on the folder: `POST /api/grants` with subject=user/bob, resource=folder/Shared, perms=[read] + - Bob calls `GET /api/folders/{folder_id}/contents` → 200 (was 404 before grant) + - Bob calls `PUT /api/folders/{folder_id}/rename` → 404 (no `update` grant) + +2. **Cascading** + - Alice creates a sub-folder `Shared/Inner` + - Alice uploads a file `vacation.jpg` inside `Inner` + - Bob (with `read` on `Shared`) calls `GET /api/files/{file_id}` → 200 (cascaded via lpath) + +3. **Incoming list** + - Bob calls `GET /api/grants/incoming` → returns 1 entry with the folder, permission=read + +4. **Re-share** + - Carol (new user) — Alice grants Bob `share` additionally + - Bob now successfully calls `POST /api/grants` to grant Carol `read` + - Carol calls `GET /api/folders/{folder_id}/contents` → 200 + +5. **Revoke** + - Alice deletes Bob's grant via `DELETE /api/grants/{grant_id}` → 204 + - Bob's `GET /api/folders/{folder_id}/contents` → 404 + +6. **Roles** + - Alice grants `POST /api/grants` with `role: "editor"` for Bob on a new folder + - Bob can read AND rename a file inside (Editor includes `update`) + - Bob CANNOT delete the folder (Editor excludes `delete`) → 404 + - Alice calls `PUT /api/grants/role` with `role: "admin"` for Bob + - Bob can now delete the folder → 200 + - Alice calls `PUT /api/grants/role` with `role: "viewer"` for Bob + - Bob loses update/delete/comment/create/share; can only read → rename returns 404 + +7. **Token unification (regression)** + - `permissions.hurl` already covers existing share-link flows. After migration, those still pass — the engine reads from `access_grants` for token subjects, transparently. + +### Unit tests +- New tests in `src/application/services/idor_protection_test.rs`: + - `engine.check(non_owner, Read, file)` with no grant → false + - `engine.check(owner, _, _)` → true (owner short-circuit) without touching `access_grants` + - `engine.check(grantee, Read, file)` after `grant()` → true + - Cascade: grant on parent folder → child file check returns true + - Revoke removes the row → next check returns false +- Tests use a stub repo for owners and an in-memory grant store, OR run against the real PG via the existing test harness. + +### Storage growth sanity check (manual) +- Before migration: count rows in `storage.shares`. +- After migration: count rows in `storage.access_grants` with `subject_type='token'` ≈ shares × {1 + flag count}. +- Confirm no owner-self rows were created (validates implicit-owner choice). + +--- + +## Rollout sequencing + +1. **PR 1** — migration + schema (creates `access_grants`, migrates `storage.shares` permission flags). No code changes yet. Deploy and verify the migration runs cleanly. +2. **PR 2** — `AuthorizationEngine` trait + `PgAclEngine` + DI wiring. No services changed yet — engine is built but unused. +3. **PR 3** — service integration. Replace `verify_owner` with `authz.require` in `*_with_perms` methods. Add path-based checks. Hurl integration: `permissions.hurl` must still pass (engine's owner short-circuit ensures no behavior change for existing flows). +4. **PR 4** — REST endpoints (`/api/grants/*`) + new `grants.hurl` tests covering cross-user grant/revoke/cascade scenarios. +5. **PR 5** — `share_service` writes `access_grants` rows for new token shares (so token authz goes through the engine). At this point `storage.shares.permissions_*` columns are no longer read from anywhere — drop them. + +Each PR is independently mergeable and the system stays functional throughout. PR 1-3 ship with zero observable change to users; PR 4 introduces the new feature; PR 5 retires the dead columns. + +--- + +## Future: caching layer (in-process + Redis) + +### Why + +Every mutating service operation calls `authz.require(...)` at least once. The cascading SQL (`gf.lpath @> target.lpath` joined against `access_grants`) is O(log N) per check thanks to the GiST index, but at scale these costs compound: + +- A batch delete of 1000 files = 1000 checks +- WebDAV PROPFIND on a deep folder may call `read` for every descendant +- A user with many active sessions hammers the same `(subject, perm, resource)` repeatedly +- Cascading means even a "no" answer requires walking the full ancestor chain — short-circuited only when the GiST index returns empty + +A cache changes the cost of repeat checks from "JOIN + ltree GiST lookup" to "HashMap get" (L1) or "Redis GET" (L2). For mostly-read workloads, hit rate should be very high. + +### Architecture — decorator over the trait + +The `AuthorizationEngine` trait is unchanged. A `CachedAuthorizationEngine` wraps any underlying engine: + +```rust +pub struct CachedAuthorizationEngine { + inner: E, + l1: moka::future::Cache, // in-process, fast, per-instance + l2: Option>, // Redis (or similar), shared across instances +} + +#[derive(Hash, Eq, PartialEq, Clone)] +struct DecisionKey { + subject: Subject, + permission: Permission, + resource: Resource, +} + +impl AuthorizationEngine for CachedAuthorizationEngine { + async fn check(&self, subject: Subject, perm: Permission, resource: Resource) + -> Result + { + let key = DecisionKey { subject, permission: perm, resource }; + + // L1: in-process + if let Some(decision) = self.l1.get(&key).await { return Ok(decision); } + + // L2: Redis + if let Some(l2) = &self.l2 + && let Some(decision) = l2.get(&key).await? { + self.l1.insert(key.clone(), decision).await; + return Ok(decision); + } + + // Miss — query the underlying engine and backfill + let decision = self.inner.check(subject, perm, resource).await?; + self.l1.insert(key.clone(), decision).await; + if let Some(l2) = &self.l2 { + l2.set(&key, decision, CACHE_TTL).await?; + } + Ok(decision) + } + + async fn grant(...) -> Result { + let g = self.inner.grant(...).await?; + self.invalidate_for(g.subject, g.resource).await; + Ok(g) + } + + async fn revoke(...) -> Result<(), _> { + self.inner.revoke(...).await?; + // need the affected (subject, resource) — revoke() takes only grant_id today, + // so the trait gains a small helper or returns the deleted grant for invalidation. + Ok(()) + } +} +``` + +### Three tiers worth distinguishing + +1. **Per-request cache** (cheapest to ship). A `HashMap` lives in a request extension. Cleared at request end. Avoids repeat checks during a single batch op (e.g., a 1000-file delete only hits the DB once per unique `(subject, perm, file)`). No invalidation problem — request scope. + +2. **In-process L1** (`moka::future::Cache`). Bounded LRU with TTL. Per-server-instance. Hit on hot resources, no network. Invalidated on local `grant`/`revoke`. + +3. **Distributed L2** (Redis). Shared across multiple OxiCloud server instances. Worth adding only when running multi-instance (HA / horizontal scale). Cross-instance invalidation via Redis pub/sub or short TTL. + +### Invalidation — the hard part + +Cascading makes per-key invalidation hard. When Alice grants Bob `read` on folder F: + +- Bob's `read` on F becomes true → invalidate `(bob, read, F)` +- Bob's `read` on every descendant of F also becomes true (live cascade) → invalidate `(bob, read, child)` for every child + +There's no efficient way to enumerate all descendants and invalidate each entry. Three pragmatic options: + +| Strategy | Granularity | Implementation cost | Trade-off | +|---|---|---|---| +| **Subject-scoped flush** | All cached entries for `subject` regardless of resource | Cheap (one `bucket -> drop`) | Coarse — bob's checks on unrelated resources also dropped | +| **Resource-scoped flush** | All entries on `resource` and its descendants | Need to walk ltree on invalidation OR mark a "version" on the folder root | More targeted but more code | +| **Short TTL + eventual consistency** | None — wait for TTL | Trivial | Stale `true` after revoke for up to TTL seconds (bad), stale `false` after grant for up to TTL seconds (mildly annoying) | + +Recommendation when this lands: subject-scoped flush as the simple default; switch to resource-scoped flush if subject churn is too painful for cache hit rate. + +### Cache key normalization for cascading + +Important detail: the cached entry for "bob can read folder F" doesn't need a separate entry per descendant. The engine's `check(bob, read, child)` would still go through the SQL because the cache key is `(bob, read, child)`, distinct from `(bob, read, F)`. So caching gives no descendant boost UNLESS we: + +- Pre-resolve to "bob's effective grants" once (list all `(subject_id, permission, resource_id)` rows for bob) and cache that bundle, then evaluate any `check()` against the in-memory bundle. This is a classic Zanzibar-style "user list" cache. + +That's a separate L1 design: cache the **bundle** of bob's grants, not individual decisions. Hit rate is high (one cached blob per active user). Invalidation is per-subject (when bob receives/loses a grant). The check becomes "is the requested resource an ltree descendant of any folder in bob's grant bundle?" — done in process, no DB round-trip. + +This is probably the right L1 shape for OxiCloud given the cascade semantics. + +### Config + +```rust +// In OxiCloud config: +OXICLOUD_AUTHZ_CACHE=disabled // default in v1 +OXICLOUD_AUTHZ_CACHE=in_memory // L1 only +OXICLOUD_AUTHZ_CACHE=redis // L1 + L2 (requires OXICLOUD_REDIS_URL) +OXICLOUD_AUTHZ_CACHE_TTL=300 // seconds +``` + +The engine selection in `common/di.rs` wraps the underlying `PgAclEngine` based on this config. Disabled by default to keep v1 minimal. + +### Why this is a clean follow-up, not v1 + +- The `AuthorizationEngine` trait is unchanged → the cache is a pure decorator +- Owner short-circuit already avoids the DB for the most common case (caller acting on own resources) — caching's marginal value is highest only once cross-user grants are common +- Adding caching too early hides whether the uncached SQL is actually slow at production scale; better to measure first +- Redis adds a new infrastructure dependency; introducing it before there's measured pressure is premature + +### When to revisit + +Add per-request cache when batch ops show repeated DB checks in tracing. Add L1 in-process cache when single-instance `check` p99 latency exceeds a threshold under cross-user workloads. Add L2 Redis only when running multi-instance and cross-instance cache coherence becomes a hit-rate problem. + +--- + +## Future (v2): extend ReBAC to calendars, address books, playlists + +Three resource types already have user-to-user sharing implemented as bespoke per-feature tables. After v1 proves the engine shape on files/folders, absorb them in a follow-up plan per resource type. + +### Existing share infrastructure to migrate + +| Resource | Today's share table | Today's permission shape | +|---|---|---| +| Calendar (CalDAV) | `caldav.calendar_shares (calendar_id, user_id, access_level)` | `'read' | 'write' | 'owner'` | +| Address book (CardDAV) | `carddav.address_book_shares (address_book_id, user_id, can_write)` | binary `can_write` | +| Playlist (audio) | `audio.playlist_shares (playlist_id, user_id, can_write)` | binary `can_write` | + +### Required changes per resource type + +Each migration is small and self-contained: + +1. **Schema** — extend `resource_type` CHECK constraint: + ```sql + ALTER TABLE storage.access_grants + DROP CONSTRAINT access_grants_resource_type_check, + ADD CONSTRAINT access_grants_resource_type_check + CHECK (resource_type IN ('folder', 'file', 'calendar', 'address_book', 'playlist')); + ``` +2. **Domain** — extend `Resource` enum with `Calendar(Uuid)`, `AddressBook(Uuid)`, `Playlist(Uuid)`. +3. **Engine** — no cascading needed (these are flat containers, not trees). The `check()` SQL becomes a simple direct lookup with no ltree join for these branches. +4. **Service refactor** — remove the bespoke `share_calendar` / `share_address_book` / `share_playlist` methods. Sharing goes through `POST /api/grants` uniformly. +5. **Cleanup triggers** — add AFTER DELETE triggers on `caldav.calendars`, `carddav.address_books`, `audio.playlists` (same pattern as v1 triggers on `storage.folders`/`storage.files`). +6. **Data migration** — convert existing share rows: + ```sql + INSERT INTO storage.access_grants (subject_type, subject_id, resource_type, resource_id, permission, granted_by) + SELECT 'user', cs.user_id, 'calendar', cs.calendar_id, p.perm, c.owner_id + FROM caldav.calendar_shares cs + JOIN caldav.calendars c ON c.id = cs.calendar_id + CROSS JOIN LATERAL ( + SELECT unnest(CASE cs.access_level + WHEN 'read' THEN ARRAY['read'] + WHEN 'write' THEN ARRAY['read','update','create','delete'] + WHEN 'owner' THEN ARRAY['read','update','create','delete','share'] + END) AS perm + ) p; + -- Same shape for address_book_shares (FALSE → ['read'], TRUE → ['read','update','create','delete']) + -- Same shape for playlist_shares. + ``` +7. **Protocol mapping** (CalDAV / CardDAV only) — the WebDAV sharing properties (``, ``) need to be re-implemented on top of the new grants. This is the largest unknown and the main reason for deferral. + +### Why deferred, not in v1 + +- v1 must prove the `AuthorizationEngine` trait shape works before three more services land on it. If the trait needs an adjustment after running it on files, fixing it before three more migrations is much cheaper. +- The CalDAV/CardDAV protocol layer expects sharing semantics expressed via WebDAV properties — that's its own piece of work decoupled from the v1 grant table. +- Calendars/playlists are niche compared to file sharing — low migration risk if deferred. +- The 6-permission model already accommodates these without extension; the change is mechanical, just not yet. + +### Suggested rollout (one PR per resource) + +- **PR A** — calendars: schema constraint + Resource enum + CalendarService refactor + Hurl tests + CalDAV property mapping +- **PR B** — address books: same shape, simpler (binary `can_write`) +- **PR C** — playlists: same shape, also binary +- Each PR drops the corresponding bespoke share table at the end. + +--- + +## Future: OpenFGA plug-in + +Implementing `OpenFgaEngine` later requires: +1. Define the OpenFGA model: + ``` + type folder + relations + define parent: [folder] + define reader: [user, folder#reader] + define creator: [user, folder#creator] + define updater: [user, folder#updater] + define deleter: [user, folder#deleter] + define sharer: [user, folder#sharer] + define owner: [user] + type file + relations + define parent: [folder] + define reader: [user, folder#reader] + ... + ``` +2. On engine init, sync owner relationships (walk `storage.folders` + `storage.files`). +3. On every `grant()`, also write the tuple to OpenFGA. +4. On `check()`, query OpenFGA's `/check` endpoint. + +The `AuthorizationEngine` trait shape is identical, so swapping engines is a configuration change. The PG engine remains the source of truth for `storage.access_grants` rows; OpenFGA becomes an indexed read cache. From cba9be8c210f836c9310a4fbc7d65aec7755165d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 20 May 2026 22:56:00 +0200 Subject: [PATCH 05/13] feat(rebac): first pass --- CLAUDE.md | 6 + .../20260520000000_rebac_access_grants.sql | 161 +++++++ src/application/dtos/grant_dto.rs | 222 +++++++++ src/application/dtos/mod.rs | 1 + src/application/ports/authorization_ports.rs | 87 ++++ src/application/ports/mod.rs | 1 + .../services/batch_operations_test.rs | 15 +- .../services/file_management_service.rs | 109 ++--- .../services/file_retrieval_service.rs | 46 +- .../services/file_use_case_factory.rs | 11 +- src/application/services/folder_service.rs | 152 +++--- src/common/di.rs | 61 ++- src/domain/services/authorization.rs | 205 ++++++++ src/domain/services/mod.rs | 1 + .../pg/file_blob_read_repository.rs | 14 + src/infrastructure/services/mod.rs | 1 + src/infrastructure/services/pg_acl_engine.rs | 436 ++++++++++++++++++ src/interfaces/api/handlers/grant_handler.rs | 360 +++++++++++++++ src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/routes.rs | 15 + tests/api/grants.hurl | 303 ++++++++++++ tests/api/run.sh | 3 +- 22 files changed, 2058 insertions(+), 153 deletions(-) create mode 100644 migrations/20260520000000_rebac_access_grants.sql create mode 100644 src/application/dtos/grant_dto.rs create mode 100644 src/application/ports/authorization_ports.rs create mode 100644 src/domain/services/authorization.rs create mode 100644 src/infrastructure/services/pg_acl_engine.rs create mode 100644 src/interfaces/api/handlers/grant_handler.rs create mode 100644 tests/api/grants.hurl diff --git a/CLAUDE.md b/CLAUDE.md index a5a38694..e3b5e70c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,12 @@ Never duplicate logic across handlers or services. If the same behaviour is need - Reusable infrastructure behaviour → method on the relevant service struct - Shared port behaviour → default method on the trait +### Authorization (AuthZ) + +**AuthZ is enforced exclusively in the application service layer, never in handlers.** All permission checks go through `AuthorizationEngine` (port: `application/ports/authorization_ports.rs`) via service methods named with the `_with_perms` suffix. HTTP handlers (REST, WebDAV, NextCloud, CalDAV, CardDAV) authenticate the caller and pass `caller_id` into the service — they MUST NOT perform their own ownership/permission checks. The authentication middleware extracts the caller; the service decides if the action is allowed. + +This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation. + # Frontend part ## Code conventions diff --git a/migrations/20260520000000_rebac_access_grants.sql b/migrations/20260520000000_rebac_access_grants.sql new file mode 100644 index 00000000..c0b18721 --- /dev/null +++ b/migrations/20260520000000_rebac_access_grants.sql @@ -0,0 +1,161 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- ReBAC: access_grants table + lifecycle cleanup triggers + data migration +-- ════════════════════════════════════════════════════════════════════════════ +-- PR 1 of the ReBAC rollout. Schema and data only — no code changes yet. +-- +-- This migration: +-- 1. Creates storage.access_grants (the single grant table for ReBAC) +-- 2. Installs AFTER DELETE triggers so lifecycle cleanup is enforced at the +-- DB level even if a future code path bypasses the service layer +-- 3. Migrates existing storage.shares permission flags into access_grants +-- rows with subject_type='token' +-- +-- The storage.shares.permissions_* columns are NOT dropped here. They stay +-- until PR 5 (share_service is updated to read from access_grants instead). +-- See /Users/ed/.claude/plans/compiled-shimmying-bonbon.md → "Rollout sequencing". + + +-- ── 1. The grant table ────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS storage.access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Subject (who has the permission) + -- 'user' → auth.users.id + -- 'group' → future: group membership + -- 'token' → refers to storage.shares.id (anonymous link) + -- 'external' → future: refers to auth.external_subjects.id + -- (Open Cloud Mesh / federated OIDC) + subject_type TEXT NOT NULL + CHECK (subject_type IN ('user', 'group', 'token', 'external')), + subject_id UUID NOT NULL, + + -- Resource (what the permission is on) + resource_type TEXT NOT NULL + CHECK (resource_type IN ('folder', 'file')), + resource_id UUID NOT NULL, + + -- Permission (what action is allowed) + permission TEXT NOT NULL + CHECK (permission IN ('read', 'create', 'share', 'comment', 'delete', 'update')), + + -- Audit + granted_by UUID NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (subject_type, subject_id, resource_type, resource_id, permission) +); + +CREATE INDEX IF NOT EXISTS idx_grants_subject + ON storage.access_grants (subject_type, subject_id); + +CREATE INDEX IF NOT EXISTS idx_grants_resource + ON storage.access_grants (resource_type, resource_id); + +COMMENT ON TABLE storage.access_grants IS + 'ReBAC grant table — subject × resource × permission. Owner is implicit ' + 'via storage.folders.user_id / storage.files.user_id (no rows here for owners).'; + + +-- ── 2. Lifecycle cleanup triggers (defense-in-depth) ──────────────────────── +-- These fire AFTER DELETE on the resource/subject tables so stale grants can +-- never outlive their target. The application layer also calls explicit +-- engine.revoke_all_for_* on the canonical paths. + +CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM storage.access_grants + WHERE resource_type = TG_ARGV[0] + AND resource_id = OLD.id; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders; +CREATE TRIGGER trg_cleanup_grants_folder + AFTER DELETE ON storage.folders + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder'); + +DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files; +CREATE TRIGGER trg_cleanup_grants_file + AFTER DELETE ON storage.files + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file'); + + +CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM storage.access_grants + WHERE subject_type = TG_ARGV[0] + AND subject_id = OLD.id; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users; +CREATE TRIGGER trg_cleanup_grants_user + AFTER DELETE ON auth.users + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user'); + +DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares; +CREATE TRIGGER trg_cleanup_grants_token + AFTER DELETE ON storage.shares + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token'); + + +-- ── 3. Data migration from storage.shares ─────────────────────────────────── +-- Each existing share row becomes one or more access_grants rows with +-- subject_type='token', subject_id=shares.id. +-- +-- The old model's permission flags map to the new model as: +-- permissions_read → ['read'] +-- permissions_write → ['read', 'create', 'update', 'delete'] +-- (write implies full mutation rights) +-- permissions_reshare → ['share'] +-- +-- WHERE NOT EXISTS guards make this idempotent — re-running the migration +-- won't create duplicates. + +INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by) +SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by + FROM storage.shares s + WHERE s.permissions_read + AND NOT EXISTS ( + SELECT 1 FROM storage.access_grants g + WHERE g.subject_type = 'token' + AND g.subject_id = s.id + AND g.resource_id = s.item_id::uuid + AND g.permission = 'read' + ); + +INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by) +SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by + FROM storage.shares s + CROSS JOIN (VALUES ('read'), ('create'), ('update'), ('delete')) AS p(perm) + WHERE s.permissions_write + AND NOT EXISTS ( + SELECT 1 FROM storage.access_grants g + WHERE g.subject_type = 'token' + AND g.subject_id = s.id + AND g.resource_id = s.item_id::uuid + AND g.permission = p.perm + ); + +INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by) +SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by + FROM storage.shares s + WHERE s.permissions_reshare + AND NOT EXISTS ( + SELECT 1 FROM storage.access_grants g + WHERE g.subject_type = 'token' + AND g.subject_id = s.id + AND g.resource_id = s.item_id::uuid + AND g.permission = 'share' + ); diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs new file mode 100644 index 00000000..d45f01ae --- /dev/null +++ b/src/application/dtos/grant_dto.rs @@ -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 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 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 for Resource { + fn from(dto: ResourceDto) -> Self { + match dto.kind { + ResourceTypeDto::Folder => Resource::Folder(dto.id), + ResourceTypeDto::File => Resource::File(dto.id), + } + } +} + +impl From 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 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 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>, + #[serde(default)] + pub role: Option, +} + +/// `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, +} + +impl From 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, + } + } +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 6efa5558..52cb3135 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -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; diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs new file mode 100644 index 00000000..2ded9878 --- /dev/null +++ b/src/application/ports/authorization_ports.rs @@ -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; + + /// 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, + ) -> Result, 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, 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, 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; + + /// 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; + + /// 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; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index cccc6c8e..8988aad0 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -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; diff --git a/src/application/services/batch_operations_test.rs b/src/application/services/batch_operations_test.rs index b0039e82..a01e145c 100644 --- a/src/application/services/batch_operations_test.rs +++ b/src/application/services/batch_operations_test.rs @@ -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, diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 98c1aad4..f234e0f7 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -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, - file_read: Option>, - folder_repo: Option>, trash_service: Option>, content_cache: Option>, + authz: Arc, /// Hooks fired after a file is permanently deleted. file_deleted_hooks: Vec>, } impl FileManagementService { - /// Creates a new FileManagementService. - pub fn new(file_repository: Arc) -> 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, trash_service: Option>, - file_read: Option>, - folder_repo: Option>, + _file_read: Option>, + _folder_repo: Option>, content_cache: Option>, + authz: Arc, ) -> 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, ) -> Result { - // 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, ) -> Result { - 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 { - 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 { - 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, dest_name: Option, ) -> Result { - // 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 diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 2587b9af..037049a6 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -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, content_cache: Option>, transcode: Option>, + authz: Option>, } 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) -> 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, content_cache: Arc, transcode: Arc, + authz: Arc, ) -> 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 { - 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 { // 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> + 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, ) -> Result> + 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 } diff --git a/src/application/services/file_use_case_factory.rs b/src/application/services/file_use_case_factory.rs index fe092361..83c259ab 100644 --- a/src/application/services/file_use_case_factory.rs +++ b/src/application/services/file_use_case_factory.rs @@ -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, file_write_repository: Arc, + authz: Arc, } impl AppFileUseCaseFactory { @@ -18,10 +20,12 @@ impl AppFileUseCaseFactory { pub fn new( file_read_repository: Arc, file_write_repository: Arc, + authz: Arc, ) -> 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 { - 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(), )) } } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 7c7ee192..0a3fc2df 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -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, + authz: Arc, } impl FolderService { /// Creates a new folder service - pub fn new(folder_storage: Arc) -> Self { - Self { folder_storage } + pub fn new(folder_storage: Arc, authz: Arc) -> 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 { + 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 { - 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 { - // 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 { - // 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", diff --git a/src/common/di.rs b/src/common/di.rs index d0f5a5fe..ffabe91c 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -350,9 +350,13 @@ impl AppServiceFactory { repos: &RepositoryServices, trash_service: Option>, db_pool: &Arc, + authz: &Arc, ) -> ApplicationServices { // Main services - let folder_service = Arc::new(FolderService::new(repos.folder_repository.clone())); + let folder_service = Arc::new(FolderService::new( + repos.folder_repository.clone(), + authz.clone(), + )); // Refactored services with all infrastructure ports // In blob model, dedup is handled by the repository — no separate write-behind needed @@ -374,6 +378,7 @@ impl AppServiceFactory { repos.file_read_repository.clone(), core.file_content_cache.clone(), core.image_transcode_service.clone(), + authz.clone(), )); // FileManagementService — ref_count handled by PG trigger, no dedup port needed @@ -384,6 +389,7 @@ impl AppServiceFactory { Some(repos.file_read_repository.clone()), Some(repos.folder_repository.clone()), Some(core.file_content_cache.clone()), + authz.clone(), ) .with_file_deleted_hook(core.thumbnail_service.clone()), ); @@ -391,6 +397,7 @@ impl AppServiceFactory { let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( repos.file_read_repository.clone(), repos.file_write_repository.clone(), + authz.clone(), )); let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone())); @@ -605,9 +612,22 @@ impl AppServiceFactory { // 3. Trash service (needed before application services) let trash_service = self.create_trash_service(&repos, &core).await; - // 4. Application services (with trash already wired) - let mut apps = - self.create_application_services(&core, &repos, trash_service.clone(), &pool); + // 3b. Authorization engine — must exist before application services + // because services hold an Arc for ReBAC checks. + let authorization = build_authorization_engine( + pool.clone(), + repos.folder_repository.clone(), + repos.file_read_repository.clone(), + ); + + // 4. Application services (with trash + authz already wired) + let mut apps = self.create_application_services( + &core, + &repos, + trash_service.clone(), + &pool, + &authorization, + ); // 5. Share service let share_service = self.create_share_service(&repos, &pool); @@ -777,6 +797,7 @@ impl AppServiceFactory { path_resolver: None, webdav_lock_store: crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), + authorization, }; // 9b. Wire admin settings service when auth is available @@ -1092,6 +1113,38 @@ pub struct AppState { Option>, pub webdav_lock_store: Arc, + /// ReBAC authorization engine — all service-layer permission checks go + /// through this. Concrete type today is `PgAclEngine`; the + /// `AuthorizationEngine` trait describes the contract. When alternate + /// implementations land (OpenFGA, cached decorator), swap this field for + /// an enum dispatcher or `Arc` (with + /// `async_trait` boxing). + pub authorization: Arc, } // All AppState construction is done via struct literal in build_app_state(). + +/// Builds the authorization engine. Today this only constructs `PgAclEngine`; +/// the `OXICLOUD_AUTHZ_ENGINE` env var is reserved for future alternate +/// implementations (e.g. `openfga`). +fn build_authorization_engine( + pool: Arc, + folder_repo: Arc< + crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository, + >, + file_repo: Arc< + crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository, + >, +) -> Arc { + use crate::infrastructure::services::pg_acl_engine::PgAclEngine; + + if let Ok(other) = std::env::var("OXICLOUD_AUTHZ_ENGINE") + && other != "postgres" + && !other.is_empty() + { + panic!( + "OXICLOUD_AUTHZ_ENGINE={other:?} is not yet supported. Only 'postgres' is implemented; leave the variable unset to use the default." + ); + } + Arc::new(PgAclEngine::new(pool, folder_repo, file_repo)) +} diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs new file mode 100644 index 00000000..98036d81 --- /dev/null +++ b/src/domain/services/authorization.rs @@ -0,0 +1,205 @@ +//! Domain types for the ReBAC authorization model. +//! +//! These types are storage-agnostic — they describe the relationship between +//! a subject (who), a resource (what), and a permission (action). The +//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation +//! maps them to / from `storage.access_grants` rows. + +use uuid::Uuid; + +// ════════════════════════════════════════════════════════════════════════════ +// Subject — who has the permission +// ════════════════════════════════════════════════════════════════════════════ + +/// A principal that can be granted permissions. +/// +/// All variants carry a `Uuid` that uniquely identifies the subject within +/// its type's namespace. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Subject { + /// A registered OxiCloud user (`auth.users.id`). + User(Uuid), + /// A user group (reserved for future use; no group CRUD in v1). + Group(Uuid), + /// An anonymous share token (`storage.shares.id`). + Token(Uuid), + /// A federated identity from another server — Open Cloud Mesh, external + /// OIDC, etc. Refers to `auth.external_subjects.id` (future table). + External(Uuid), +} + +impl Subject { + /// SQL discriminator string matching the `subject_type` CHECK constraint. + pub fn type_str(&self) -> &'static str { + match self { + Subject::User(_) => "user", + Subject::Group(_) => "group", + Subject::Token(_) => "token", + Subject::External(_) => "external", + } + } + + /// The raw UUID regardless of variant. + pub fn id(&self) -> Uuid { + match self { + Subject::User(id) | Subject::Group(id) | Subject::Token(id) | Subject::External(id) => { + *id + } + } + } + + /// Reconstruct from a SQL row's `(subject_type, subject_id)` pair. + pub fn from_parts(subject_type: &str, id: Uuid) -> Option { + match subject_type { + "user" => Some(Subject::User(id)), + "group" => Some(Subject::Group(id)), + "token" => Some(Subject::Token(id)), + "external" => Some(Subject::External(id)), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Resource — what the permission is on +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Resource { + Folder(Uuid), + File(Uuid), +} + +impl Resource { + pub fn type_str(&self) -> &'static str { + match self { + Resource::Folder(_) => "folder", + Resource::File(_) => "file", + } + } + + pub fn id(&self) -> Uuid { + match self { + Resource::Folder(id) | Resource::File(id) => *id, + } + } + + pub fn from_parts(resource_type: &str, id: Uuid) -> Option { + match resource_type { + "folder" => Some(Resource::Folder(id)), + "file" => Some(Resource::File(id)), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Permission — what action is allowed +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Permission { + /// View resource content / list folder contents. + Read, + /// Create child resources inside (only meaningful on folders). + Create, + /// Grant permissions to other subjects. + Share, + /// Add comments (reserved — comments feature not implemented yet). + Comment, + /// Delete the resource. + Delete, + /// Modify the resource (rename, move, edit content). + Update, +} + +impl Permission { + /// Every permission, in a stable order. Used by `Role::expand()` and SQL + /// `permission = ANY(...)` lookups. + pub const ALL: [Permission; 6] = [ + Permission::Read, + Permission::Create, + Permission::Share, + Permission::Comment, + Permission::Delete, + Permission::Update, + ]; + + pub fn as_str(&self) -> &'static str { + match self { + Permission::Read => "read", + Permission::Create => "create", + Permission::Share => "share", + Permission::Comment => "comment", + Permission::Delete => "delete", + Permission::Update => "update", + } + } + + /// Parse a permission from its SQL discriminator string. Returns None + /// for unknown values. + pub fn parse(s: &str) -> Option { + match s { + "read" => Some(Permission::Read), + "create" => Some(Permission::Create), + "share" => Some(Permission::Share), + "comment" => Some(Permission::Comment), + "delete" => Some(Permission::Delete), + "update" => Some(Permission::Update), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Grant — a row in storage.access_grants +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Debug)] +pub struct Grant { + pub id: Uuid, + pub subject: Subject, + pub resource: Resource, + pub permission: Permission, + pub granted_by: Uuid, + pub granted_at: chrono::DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subject_roundtrip() { + let id = Uuid::new_v4(); + let cases = [ + Subject::User(id), + Subject::Group(id), + Subject::Token(id), + Subject::External(id), + ]; + for s in cases { + let back = Subject::from_parts(s.type_str(), s.id()).unwrap(); + assert_eq!(s, back); + } + assert!(Subject::from_parts("unknown", id).is_none()); + } + + #[test] + fn resource_roundtrip() { + let id = Uuid::new_v4(); + for r in [Resource::Folder(id), Resource::File(id)] { + let back = Resource::from_parts(r.type_str(), r.id()).unwrap(); + assert_eq!(r, back); + } + assert!(Resource::from_parts("calendar", id).is_none()); + } + + #[test] + fn permission_roundtrip() { + for p in Permission::ALL { + assert_eq!(Permission::parse(p.as_str()), Some(p)); + } + assert!(Permission::parse("administrate").is_none()); + } +} diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index 83d4df8f..93be8d78 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -1,3 +1,4 @@ +pub mod authorization; pub mod i18n_service; pub mod path_service; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 82f4edb1..b84d346f 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -80,6 +80,20 @@ impl FileBlobReadRepository { } } + /// Returns the user_id (owner) for a given file ID. + /// Mirrors `FolderDbRepository::get_folder_user_id`. + /// Used by the AuthorizationEngine for owner short-circuit. + pub async fn get_file_user_id(&self, file_id: &str) -> Result { + sqlx::query_scalar::<_, uuid::Uuid>( + "SELECT user_id FROM storage.files WHERE id = $1::uuid AND NOT is_trashed", + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}")))? + .ok_or_else(|| DomainError::not_found("File", file_id)) + } + /// Creates a stub instance for testing — never hits PG. #[cfg(test)] pub fn new_stub() -> Self { diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 1952d231..5d625379 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -19,6 +19,7 @@ pub mod oidc_service; pub mod password_hasher; pub mod path_resolver_service; pub mod path_service; +pub mod pg_acl_engine; pub mod retry_blob_backend; pub mod s3_blob_backend; pub mod share_unlock_cookie; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs new file mode 100644 index 00000000..6711b110 --- /dev/null +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -0,0 +1,436 @@ +//! PostgreSQL-backed implementation of `AuthorizationEngine`. +//! +//! Stores grants in `storage.access_grants` (see migration +//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check +//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, +//! using the existing GiST index for O(log N) traversal. +//! +//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id` +//! are checked first via dedicated helpers; if the caller is the owner, no +//! SQL against `access_grants` happens. +//! +//! ## Lifecycle cleanup +//! +//! In v1, cleanup of grant rows when a resource or subject is permanently +//! deleted is enforced by **DB triggers** (`trg_cleanup_grants_*` in the +//! migration). The application layer does not call `revoke_all_for_*` +//! explicitly today — the triggers are the canonical path because they +//! also catch bulk SQL maintenance, admin scripts, and any code path that +//! bypasses the service layer. +//! +//! The `revoke_all_for_resource` / `revoke_all_for_subject` methods exist +//! on the trait for future use cases: +//! - **Caching** (planned) — a `CachedAuthorizationEngine` decorator needs +//! to see the invalidation event at the engine boundary, not just at the +//! SQL level. When caching lands, services will start calling these +//! methods explicitly before/around delete operations. +//! - **Alternate engines** (OpenFGA, future) — engines that don't share a +//! DB transaction with the resource table need an explicit signal to +//! delete their tuples. + +use std::sync::Arc; +use uuid::Uuid; + +use sqlx::PgPool; + +use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::common::errors::DomainError; +use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; +use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; +use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; + +pub struct PgAclEngine { + pool: Arc, + folder_repo: Arc, + file_repo: Arc, +} + +impl PgAclEngine { + pub fn new( + pool: Arc, + folder_repo: Arc, + file_repo: Arc, + ) -> Self { + Self { + pool, + folder_repo, + file_repo, + } + } + + /// Creates a stub instance for tests that need to construct services + /// without a real PostgreSQL pool. Connecting to the lazy pool will + /// fail at runtime — only safe in tests that exercise types, not actual + /// authz queries. + #[cfg(test)] + pub fn new_stub() -> Self { + let pool = sqlx::pool::PoolOptions::::new() + .max_connections(1) + .connect_lazy("postgres://invalid:5432/none") + .unwrap(); + Self { + pool: Arc::new(pool), + folder_repo: Arc::new(FolderDbRepository::new_stub()), + file_repo: Arc::new(FileBlobReadRepository::new_stub()), + } + } + + /// Returns the owner UUID for any resource type. + async fn owner_of(&self, resource: Resource) -> Result { + match resource { + Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await, + Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await, + } + } + + /// Cascading check for folders: is there a grant on any ancestor folder + /// (including the target itself) in this subject + permission? + /// Uses GiST index on `storage.folders.lpath`. + async fn folder_cascade_grant_exists( + &self, + subject: Subject, + permission: Permission, + folder_id: Uuid, + ) -> Result { + let exists: Option = sqlx::query_scalar( + r#" + SELECT 1 + FROM storage.access_grants g + JOIN storage.folders gf ON gf.id = g.resource_id + WHERE g.subject_type = $1 + AND g.subject_id = $2 + AND g.permission = $3 + AND g.resource_type = 'folder' + AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4) + LIMIT 1 + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(permission.as_str()) + .bind(folder_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("folder cascade: {e}")))?; + + Ok(exists.is_some()) + } + + /// Cascading check for files: either a direct file grant OR a grant on + /// any ancestor folder of the file's containing folder. + async fn file_cascade_grant_exists( + &self, + subject: Subject, + permission: Permission, + file_id: Uuid, + ) -> Result { + let exists: Option = sqlx::query_scalar( + r#" + SELECT 1 + FROM ( + -- direct file grant + SELECT 1 + FROM storage.access_grants + WHERE subject_type = $1 AND subject_id = $2 AND permission = $3 + AND resource_type = 'file' AND resource_id = $4 + UNION ALL + -- cascading from any ancestor folder of the file's containing folder + SELECT 1 + FROM storage.access_grants g + JOIN storage.folders gf ON gf.id = g.resource_id + JOIN storage.files target_f ON target_f.id = $4 + WHERE g.subject_type = $1 + AND g.subject_id = $2 + AND g.permission = $3 + AND g.resource_type = 'folder' + AND target_f.folder_id IS NOT NULL + AND gf.lpath @> (SELECT lpath FROM storage.folders + WHERE id = target_f.folder_id) + ) any_match + LIMIT 1 + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(permission.as_str()) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("file cascade: {e}")))?; + + Ok(exists.is_some()) + } + + /// Look up a single grant by id. Returns `(resource, granted_by)` so + /// the REST `DELETE /api/grants/{id}` handler can decide authorization + /// without a second round-trip. Returns `Ok(None)` if no such grant. + pub async fn find_grant_by_id( + &self, + grant_id: Uuid, + ) -> Result, DomainError> { + let row: Option<(String, Uuid, Uuid)> = sqlx::query_as( + "SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1", + ) + .bind(grant_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?; + + let Some((rt, rid, granter)) = row else { + return Ok(None); + }; + let res = Resource::from_parts(&rt, rid) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; + Ok(Some((res, granter))) + } + + /// Decode a (id, subject_type, subject_id, resource_type, resource_id, + /// permission, granted_by, granted_at) row into a `Grant`. + fn row_to_grant( + row: ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + ) -> Result { + let subject = Subject::from_parts(&row.1, row.2) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?; + let resource = Resource::from_parts(&row.3, row.4) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; + let permission = Permission::parse(&row.5) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?; + Ok(Grant { + id: row.0, + subject, + resource, + permission, + granted_by: row.6, + granted_at: row.7, + }) + } +} + +impl AuthorizationEngine for PgAclEngine { + async fn check( + &self, + subject: Subject, + permission: Permission, + resource: Resource, + ) -> Result { + // Owner short-circuit (only for User subjects — groups/tokens/external + // are never owners of resources). + if let Subject::User(uid) = subject { + match self.owner_of(resource).await { + Ok(owner) if owner == uid => return Ok(true), + Ok(_) => { /* not owner — fall through to grants */ } + Err(e) if e.kind == crate::common::errors::ErrorKind::NotFound => { + // Resource doesn't exist — no permission. Return false + // rather than propagating NotFound; the caller (`require`) + // converts a false back to NotFound on its own. + return Ok(false); + } + Err(e) => return Err(e), + } + } + + // Cascading grant check. + match resource { + Resource::Folder(id) => { + self.folder_cascade_grant_exists(subject, permission, id) + .await + } + Resource::File(id) => { + self.file_cascade_grant_exists(subject, permission, id) + .await + } + } + } + + async fn list_incoming_grants( + &self, + subject: Subject, + permission_filter: Option, + ) -> Result, DomainError> { + let perm_str = permission_filter.map(|p| p.as_str().to_string()); + + let rows = sqlx::query_as::< + _, + ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + >( + r#" + SELECT id, subject_type, subject_id, resource_type, resource_id, + permission, granted_by, granted_at + FROM storage.access_grants + WHERE subject_type = $1 + AND subject_id = $2 + AND ($3::text IS NULL OR permission = $3) + ORDER BY granted_at DESC + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(perm_str) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("list incoming: {e}")))?; + + rows.into_iter().map(Self::row_to_grant).collect() + } + + async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError> { + let rows = sqlx::query_as::< + _, + ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + >( + r#" + SELECT id, subject_type, subject_id, resource_type, resource_id, + permission, granted_by, granted_at + FROM storage.access_grants + WHERE resource_type = $1 + AND resource_id = $2 + ORDER BY granted_at DESC + "#, + ) + .bind(resource.type_str()) + .bind(resource.id()) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("list on resource: {e}")))?; + + rows.into_iter().map(Self::row_to_grant).collect() + } + + async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError> { + let rows = sqlx::query_as::< + _, + ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + >( + r#" + SELECT id, subject_type, subject_id, resource_type, resource_id, + permission, granted_by, granted_at + FROM storage.access_grants + WHERE granted_by = $1 + ORDER BY granted_at DESC + "#, + ) + .bind(granted_by) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("list outgoing: {e}")))?; + + rows.into_iter().map(Self::row_to_grant).collect() + } + + async fn grant( + &self, + granted_by: Uuid, + subject: Subject, + permission: Permission, + resource: Resource, + ) -> Result { + // Idempotent: ON CONFLICT DO UPDATE so we always return the row + // (whether newly inserted or pre-existing). The "update" is a no-op + // (granted_by/granted_at preserved from the existing row). + let row = sqlx::query_as::< + _, + ( + Uuid, + String, + Uuid, + String, + Uuid, + String, + Uuid, + chrono::DateTime, + ), + >( + r#" + INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) + DO UPDATE SET subject_type = EXCLUDED.subject_type + RETURNING id, subject_type, subject_id, resource_type, resource_id, + permission, granted_by, granted_at + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(resource.type_str()) + .bind(resource.id()) + .bind(permission.as_str()) + .bind(granted_by) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?; + + Self::row_to_grant(row) + } + + async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> { + sqlx::query("DELETE FROM storage.access_grants WHERE id = $1") + .bind(grant_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?; + Ok(()) + } + + async fn revoke_all_for_resource(&self, resource: Resource) -> Result { + let result = sqlx::query( + "DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2", + ) + .bind(resource.type_str()) + .bind(resource.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?; + + Ok(result.rows_affected() as usize) + } + + async fn revoke_all_for_subject(&self, subject: Subject) -> Result { + let result = sqlx::query( + "DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2", + ) + .bind(subject.type_str()) + .bind(subject.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?; + + Ok(result.rows_affected() as usize) + } +} diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs new file mode 100644 index 00000000..b769fa7c --- /dev/null +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -0,0 +1,360 @@ +//! REST handlers for the ReBAC grant management endpoints. +//! +//! All endpoints under `/api/grants`. The authenticated caller is taken from +//! the `AuthUser` extractor. Authorization for sharing operations is enforced +//! via `authz.require(caller, Share, resource)` — handlers never embed their +//! own checks (see CLAUDE.md § Authorization). + +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use std::sync::Arc; +use tracing::{error, info}; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::application::dtos::grant_dto::{ + CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto, + UpdateRoleDto, +}; +use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::common::errors::DomainError; +use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +// ════════════════════════════════════════════════════════════════════════════ +// POST /api/grants +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + post, + path = "/api/grants", + request_body = CreateGrantDto, + responses( + (status = 201, description = "Grant(s) created", body = Vec), + (status = 400, description = "Invalid input (both/neither of permissions+role provided)"), + (status = 404, description = "Resource not found OR caller lacks Share permission"), + ), + tag = "grants" +)] +pub async fn create_grant( + State(authz): State>, + auth_user: AuthUser, + Json(dto): Json, +) -> impl IntoResponse { + let caller_id = auth_user.id; + + // Validate: exactly one of permissions/role + let permissions: Vec = match (dto.permissions, dto.role) { + (Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(), + (None, Some(role)) => role.expand().to_vec(), + (Some(_), Some(_)) => { + return AppError::new( + StatusCode::BAD_REQUEST, + "Provide either 'permissions' or 'role', not both", + "InvalidInput", + ) + .into_response(); + } + _ => { + return AppError::new( + StatusCode::BAD_REQUEST, + "Either 'permissions' (non-empty) or 'role' is required", + "InvalidInput", + ) + .into_response(); + } + }; + + let subject: Subject = dto.subject.into(); + let resource: Resource = dto.resource.into(); + + // Caller must have Share on the resource (owners pass via short-circuit). + if let Err(e) = authz + .require(Subject::User(caller_id), Permission::Share, resource) + .await + { + return AppError::from(e).into_response(); + } + + let mut results: Vec = Vec::with_capacity(permissions.len()); + for perm in permissions { + match authz.grant(caller_id, subject, perm, resource).await { + Ok(grant) => results.push(grant.into()), + Err(err) => { + error!("grant insert failed for {perm:?}: {err}"); + return AppError::from(err).into_response(); + } + } + } + info!( + "Created {} grant(s) for subject={:?} on resource={:?} by user {}", + results.len(), + subject, + resource, + caller_id + ); + (StatusCode::CREATED, Json(results)).into_response() +} + +// ════════════════════════════════════════════════════════════════════════════ +// DELETE /api/grants/{id} +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + delete, + path = "/api/grants/{id}", + params(("id" = String, Path, description = "Grant UUID")), + responses( + (status = 204, description = "Grant revoked (or did not exist)"), + (status = 404, description = "Caller lacks Share permission on the underlying resource"), + ), + tag = "grants" +)] +pub async fn revoke_grant( + State(authz): State>, + auth_user: AuthUser, + Path(id): Path, +) -> impl IntoResponse { + let caller_id = auth_user.id; + let grant_id = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(), + }; + + // Look up the grant to find the underlying resource (and granter). + let on_resource = match find_grant_resource(&authz, grant_id).await { + Ok(Some((res, granter))) => (res, granter), + Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent + Err(e) => return AppError::from(e).into_response(), + }; + + // Caller is authorized if they are the granter OR have Share on the resource. + if on_resource.1 != caller_id + && let Err(e) = authz + .require(Subject::User(caller_id), Permission::Share, on_resource.0) + .await + { + return AppError::from(e).into_response(); + } + + if let Err(e) = authz.revoke(grant_id).await { + return AppError::from(e).into_response(); + } + info!("Revoked grant {grant_id} (caller {caller_id})"); + StatusCode::NO_CONTENT.into_response() +} + +/// Look up a grant by id and return (resource, granted_by) so the caller-auth +/// check in revoke_grant can determine if the caller is the granter or needs +/// the Share permission on the resource. Returns `Ok(None)` if no such grant. +async fn find_grant_resource( + authz: &PgAclEngine, + grant_id: Uuid, +) -> Result, DomainError> { + authz.find_grant_by_id(grant_id).await +} + +// ════════════════════════════════════════════════════════════════════════════ +// PUT /api/grants/role +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + put, + path = "/api/grants/role", + request_body = UpdateRoleDto, + responses( + (status = 200, description = "Role applied; returns the new full grant set", body = Vec), + (status = 404, description = "Resource not found or caller lacks Share"), + ), + tag = "grants" +)] +pub async fn set_role( + State(authz): State>, + auth_user: AuthUser, + Json(dto): Json, +) -> impl IntoResponse { + let caller_id = auth_user.id; + let subject: Subject = dto.subject.into(); + let resource: Resource = dto.resource.into(); + let target_perms: std::collections::HashSet = + dto.role.expand().iter().copied().collect(); + + // Caller must have Share on the resource. + if let Err(e) = authz + .require(Subject::User(caller_id), Permission::Share, resource) + .await + { + return AppError::from(e).into_response(); + } + + // Fetch current grants on the resource for this subject. + let current = match authz.list_grants_on_resource(resource).await { + Ok(g) => g, + Err(e) => return AppError::from(e).into_response(), + }; + let current_perms: std::collections::HashSet = current + .iter() + .filter(|g| g.subject == subject) + .map(|g| g.permission) + .collect(); + + // Diff and apply. + let to_add: Vec = target_perms.difference(¤t_perms).copied().collect(); + let to_remove: Vec = current_perms.difference(&target_perms).copied().collect(); + + for perm in &to_remove { + if let Some(g) = current + .iter() + .find(|g| g.subject == subject && g.permission == *perm) + && let Err(e) = authz.revoke(g.id).await + { + return AppError::from(e).into_response(); + } + } + for perm in &to_add { + if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await { + return AppError::from(e).into_response(); + } + } + + // Return the new full set. + let after = match authz.list_grants_on_resource(resource).await { + Ok(g) => g, + Err(e) => return AppError::from(e).into_response(), + }; + let mine: Vec = after + .into_iter() + .filter(|g| g.subject == subject) + .map(Into::into) + .collect(); + + info!( + "Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}", + caller_id, subject, resource, to_add, to_remove + ); + (StatusCode::OK, Json(mine)).into_response() +} + +// ════════════════════════════════════════════════════════════════════════════ +// GET /api/grants/incoming +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Deserialize, IntoParams)] +pub struct IncomingQuery { + #[serde(default)] + pub permission: Option, +} + +#[utoipa::path( + get, + path = "/api/grants/incoming", + params(IncomingQuery), + responses( + (status = 200, description = "Direct grants targeting the caller", body = Vec), + ), + tag = "grants" +)] +pub async fn list_incoming( + State(authz): State>, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let caller_id = auth_user.id; + match authz + .list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into)) + .await + { + Ok(grants) => { + let dtos: Vec = grants.into_iter().map(Into::into).collect(); + (StatusCode::OK, Json(dtos)).into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// GET /api/grants/outgoing +// ════════════════════════════════════════════════════════════════════════════ + +#[utoipa::path( + get, + path = "/api/grants/outgoing", + responses( + (status = 200, description = "Grants the caller has created", body = Vec), + ), + tag = "grants" +)] +pub async fn list_outgoing( + State(authz): State>, + auth_user: AuthUser, +) -> impl IntoResponse { + let caller_id = auth_user.id; + match authz.list_outgoing_grants(caller_id).await { + Ok(grants) => { + let dtos: Vec = grants.into_iter().map(Into::into).collect(); + (StatusCode::OK, Json(dtos)).into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// GET /api/grants?resource_type=...&resource_id=... +// (list grants on a specific resource — requires Share on it) +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Deserialize, IntoParams)] +pub struct OnResourceQuery { + pub resource_type: ResourceTypeDto, + pub resource_id: Uuid, +} + +#[utoipa::path( + get, + path = "/api/grants", + params(OnResourceQuery), + responses( + (status = 200, description = "Grants on the specified resource", body = Vec), + (status = 404, description = "Resource not found or caller lacks Share"), + ), + tag = "grants" +)] +pub async fn list_on_resource( + State(authz): State>, + auth_user: AuthUser, + Query(q): Query, +) -> impl IntoResponse { + let caller_id = auth_user.id; + let resource: Resource = ResourceDto { + kind: q.resource_type, + id: q.resource_id, + } + .into(); + + if let Err(e) = authz + .require(Subject::User(caller_id), Permission::Share, resource) + .await + { + return AppError::from(e).into_response(); + } + + match authz.list_grants_on_resource(resource).await { + Ok(grants) => { + let dtos: Vec = grants.into_iter().map(Into::into).collect(); + (StatusCode::OK, Json(dtos)).into_response() + } + Err(e) => AppError::from(e).into_response(), + } +} + +// Silence unused-import warnings for SubjectDto when only certain endpoints +// touch it directly. +#[allow(dead_code)] +fn _ensure_subject_dto_compiles(_: SubjectDto) {} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 39a0ecfd..caa75884 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -11,6 +11,7 @@ pub mod device_auth_handler; pub mod favorites_handler; pub mod file_handler; pub mod folder_handler; +pub mod grant_handler; pub mod i18n_handler; pub mod music_handler; pub mod photos_handler; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index faa58e20..872ec605 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -165,6 +165,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); let favorites_service = app_state.favorites_service.clone(); let recent_service = app_state.recent_service.clone(); + let authorization = app_state.authorization.clone(); // Initialize the batch operations service let mut batch_service_builder = BatchOperationService::default( @@ -301,6 +302,19 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { Router::new() }; + // Create routes for ReBAC grants (/api/grants) — single state: the authz engine. + let grants_router = { + use crate::interfaces::api::handlers::grant_handler; + Router::new() + .route("/", post(grant_handler::create_grant)) + .route("/", get(grant_handler::list_on_resource)) + .route("/{id}", delete(grant_handler::revoke_grant)) + .route("/role", put(grant_handler::set_role)) + .route("/incoming", get(grant_handler::list_incoming)) + .route("/outgoing", get(grant_handler::list_outgoing)) + .with_state(authorization.clone()) + }; + // Create a router without the i18n routes // Create routes for favorites if the service is available let favorites_router = if let Some(favorites_service) = favorites_service.clone() { @@ -378,6 +392,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .nest("/batch", batch_router) .nest("/search", search_router) .nest("/shares", share_router) + .nest("/grants", grants_router) .nest("/favorites", favorites_router) .nest("/recent", recent_router); diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl new file mode 100644 index 00000000..d5378479 --- /dev/null +++ b/tests/api/grants.hurl @@ -0,0 +1,303 @@ +# ============================================================= +# OxiCloud — ReBAC grant management (POST/DELETE/GET /api/grants) +# ============================================================= +# Exercises cross-user grants, cascading, roles, revoke, lifecycle +# cleanup. Uses ONLY endpoints that route through the +# AuthorizationEngine — handler-layer inline checks (e.g. +# GET /api/folders/{id}) are scheduled for cleanup separately. +# +# Runs AFTER permissions.hurl (bob already exists). Self-contained +# resources (unique names) so it doesn't depend on prior state. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (Alice), capture token + home folder. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" + +GET {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create two test users specific to this file (dave + eve). +# Avoids cross-file dependencies on bob from permissions.hurl +# and gives us their user_id directly from the create response. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "username": "dave", "password": "DavePassword1!", "email": "dave@example.com", "role": "user" } + +HTTP 201 +[Captures] +dave_user_id: jsonpath "$.id" + +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "username": "eve", "password": "EvePassword1!", "email": "eve@example.com", "role": "user" } + +HTTP 201 +[Captures] +eve_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Login dave and eve. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "dave", "password": "DavePassword1!" } + +HTTP 200 +[Captures] +dave_token: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "eve", "password": "EvePassword1!" } + +HTTP 200 +[Captures] +eve_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Alice creates a folder "grant-shared" + a child "grant-child". +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "grant-shared", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +shared_folder_id: jsonpath "$.id" + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "grant-child", "parent_id": "{{shared_folder_id}}" } + +HTTP 201 +[Captures] +child_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Without any grant, bob cannot rename Alice's folder. +# PUT /api/folders/{id}/rename goes through the engine → +# 404 (anti-enumeration). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "bob-tried" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Alice grants Bob the Viewer role. Server expands → [read]. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{dave_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Viewer cannot rename (no update grant). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "bob-tried-again" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Bob's incoming grants list contains the new grant. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{dave_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Promote Bob to Manager (adds comment, create, update, share). +# PUT /api/grants/role reconciles the row set in one call. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{dave_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "manager" +} + +HTTP 200 +[Asserts] +jsonpath "$" count == 5 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Bob can now rename (Manager includes update). +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "renamed-by-bob-as-manager" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Cascading: Bob can also rename the CHILD folder, because +# his Update grant on the parent cascades via ltree to the +# child resource — even though no direct grant on the child. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{child_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "renamed-child-via-cascade" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Bob re-shares to Carol (he has Share via Manager). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{eve_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +eve_grant_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Carol can see the grant in her incoming list. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{eve_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Bob's outgoing grants list contains the grant to Carol. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/outgoing +Authorization: Bearer {{dave_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{eve_grant_id}}')].id" == "{{eve_grant_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Demote Bob to Viewer; he loses update/share/etc. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{dave_user_id}}" }, + "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, + "role": "viewer" +} + +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].permission" == "read" + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Demoted Bob can no longer rename. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "name": "bob-tried-after-demote" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Lifecycle: Alice deletes the folder. The DB trigger +# trg_cleanup_grants_folder removes both bob's and carol's +# grants automatically (also for the cascade-deleted child). +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{child_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/folders/{{shared_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{alice_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 18 — After permanent delete, Bob's incoming list no longer +# contains the deleted folder's grant. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{dave_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Same for Carol. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{eve_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 diff --git a/tests/api/run.sh b/tests/api/run.sh index 48e9f438..3284c903 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -97,7 +97,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ "$API_DIR/contacts.hurl" \ - "$API_DIR/permissions.hurl" + "$API_DIR/permissions.hurl" \ + "$API_DIR/grants.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" From 3362e277abe7890cd88634dac09875e6efc7de34 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 11:07:04 +0200 Subject: [PATCH 06/13] feat(authz): check permission on read handlers + check create permission on folder --- src/application/ports/authorization_ports.rs | 13 ++ src/application/ports/file_ports.rs | 20 ++- src/application/ports/folder_ports.rs | 12 +- src/application/services/batch_operations.rs | 10 +- .../services/file_management_service.rs | 12 ++ .../services/file_retrieval_service.rs | 65 +++++-- src/application/services/folder_service.rs | 170 ++++++++++++------ .../services/idor_protection_test.rs | 4 +- .../services/share_browse_service.rs | 4 +- src/application/services/trash_service.rs | 44 ++++- src/common/di.rs | 15 +- src/common/stubs.rs | 37 +++- src/domain/services/authorization.rs | 38 +++- .../api/handlers/chunked_upload_handler.rs | 3 + src/interfaces/api/handlers/file_handler.rs | 92 ++++++---- src/interfaces/api/handlers/folder_handler.rs | 49 ++--- src/interfaces/api/handlers/share_handler.rs | 1 + src/interfaces/api/handlers/webdav_handler.rs | 6 +- src/interfaces/api/handlers/wopi_handler.rs | 2 +- tests/common/server.env | 1 + tests/webdav/common.sh | 10 +- 21 files changed, 428 insertions(+), 180 deletions(-) diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 2ded9878..941d4bd5 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -36,12 +36,25 @@ pub trait AuthorizationEngine: Send + Sync + 'static { resource: Resource, ) -> Result<(), DomainError> { if self.check(subject, permission, resource).await? { + tracing::debug!( + "👮🏻‍♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'", + subject, + permission, + resource + ); Ok(()) } else { let (kind, id) = match resource { Resource::Folder(id) => ("Folder", id), Resource::File(id) => ("File", id), }; + // log it for audit + tracing::info!( + "👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'", + subject, + permission, + resource + ); Err(DomainError::not_found(kind, id.to_string())) } } diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index bc2be325..50ef37cc 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -11,6 +11,7 @@ use crate::application::services::file_management_service::FileManagementService use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::file_upload_service::FileUploadService; use crate::common::errors::DomainError; +use crate::domain::services::authorization::Permission; // ───────────────────────────────────────────────────── // Upload port @@ -119,7 +120,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Returns `NotFound` if the file does not exist **or** belongs to /// another user. All user-facing handlers should use this method. - async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result; + async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result; /// Gets a file by its path (for WebDAV) async fn get_file_by_path(&self, path: &str) -> Result; @@ -131,7 +132,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Uses SQL-level `AND user_id` filtering — no in-memory post-filter. /// All user-facing list handlers should use this method. - async fn list_files_owned( + async fn list_files_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, @@ -144,7 +145,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { ) -> Result> + Send>, DomainError>; /// Gets file content as a stream, enforcing that `caller_id` is the owner. - async fn get_file_stream_owned( + async fn get_file_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -166,7 +167,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Verifies `caller_id` owns the file before returning content. /// All user-facing download handlers should use this. - async fn get_file_optimized_owned( + async fn get_file_optimized_with_perms( &self, id: &str, caller_id: Uuid, @@ -198,7 +199,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { ) -> Result> + Send>, DomainError>; /// Ownership-scoped range stream — verifies caller owns the file first. - async fn get_file_range_stream_owned( + async fn get_file_range_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -238,7 +239,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// /// Used by streaming WebDAV PROPFIND so that each user only sees their /// own files, even in shared folder_id namespaces. - async fn list_files_batch_for_owner( + async fn list_files_batch_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, @@ -256,6 +257,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Primary port for file management operations pub trait FileManagementUseCase: Send + Sync + 'static { + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + file_id: &str, + ) -> Result<(), DomainError>; + /// Moves a file, enforcing that `caller_id` is the owner. async fn move_file_with_perms( &self, diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index 1e736adc..f34e61ad 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -6,8 +6,16 @@ use crate::application::dtos::folder_dto::{ }; use crate::common::errors::DomainError; +use crate::domain::services::authorization::Permission; pub trait FolderUseCase: Send + Sync + 'static { + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + folder_id: &str, + ) -> Result<(), DomainError>; + /// Creates a new folder async fn create_folder_with_perms( &self, @@ -36,7 +44,7 @@ pub trait FolderUseCase: Send + Sync + 'static { /// Lists folders scoped to a specific owner (for user-facing endpoints). /// At root level, only returns folders belonging to this user. - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, @@ -50,7 +58,7 @@ pub trait FolderUseCase: Send + Sync + 'static { ) -> Result, DomainError>; /// Lists folders with pagination, scoped to a specific owner. - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 7bbaa9ec..921d88b4 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -330,7 +330,7 @@ impl BatchOperationService { let retrieval = self.file_retrieval.clone(); async move { - let get_result = retrieval.get_file_owned(&file_id, user_id).await; + let get_result = retrieval.get_file_with_perms(&file_id, user_id).await; (file_id, get_result) } })) @@ -717,7 +717,11 @@ impl BatchOperationService { // ── Add individual files at the root of the ZIP ────────────────── for file_id in &file_ids { - match self.file_retrieval.get_file_owned(file_id, user_id).await { + match self + .file_retrieval + .get_file_with_perms(file_id, user_id) + .await + { Ok(file_dto) => { if let Err(e) = self .add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id) @@ -790,7 +794,7 @@ impl BatchOperationService { let stream = self .file_retrieval - .get_file_stream_owned(file_id, caller_id) + .get_file_stream_with_perms(file_id, caller_id) .await .map_err(BatchOperationError::Domain)?; let mut stream = std::pin::Pin::from(stream); diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index f234e0f7..8f875a7b 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -225,6 +225,18 @@ impl FileManagementService { } impl FileManagementUseCase for FileManagementService { + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + file_id: &str, + ) -> Result<(), DomainError> { + let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?; + self.authz + .require(Subject::User(caller_id), permission, Resource::File(uuid)) + .await + } + async fn move_file_with_perms( &self, file_id: &str, diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 037049a6..d9a4fe9a 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -64,6 +64,8 @@ impl FileRetrievalService { } } + // ── private helpers ────────────────────────────────────────── + /// 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( @@ -81,7 +83,25 @@ impl FileRetrievalService { .await } - // ── private helpers ────────────────────────────────────────── + /// 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 { + return Ok(()); + }; + let authz = self.authz.as_ref().ok_or_else(|| { + DomainError::internal_error("FileRetrieval", "Authorization engine unavailable") + })?; + let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?; + authz + .require(Subject::User(caller_id), perm, Resource::Folder(uuid)) + .await + } /// Try to transcode image content to WebP and return transcoded variant. async fn try_transcode( @@ -229,12 +249,13 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(FileDto::from(file)) } - async fn get_file_owned(&self, id: &str, caller_id: Uuid) -> Result { + async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result { self.require_file(id, Permission::Read, caller_id).await?; let file = self.file_read.get_file(id).await?; Ok(FileDto::from(file)) } + // FIXME no authorisation at all async fn get_file_by_path(&self, path: &str) -> Result { // Direct SQL lookup — O(folder_depth) queries instead of O(total_files) // NOTE: This method does NOT perform any authorization check. Callers @@ -256,16 +277,24 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(files.into_iter().map(FileDto::from).collect()) } - async fn list_files_owned( + async fn list_files_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, ) -> Result, DomainError> { - let files = self - .file_read - .list_files_for_owner(folder_id, owner_id) - .await?; - Ok(files.into_iter().map(FileDto::from).collect()) + if folder_id.is_some() { + // folder id is defined, check permissions + self.require_target_folder_perm(folder_id, Permission::Read, owner_id) + .await?; + self.list_files(folder_id).await + } else { + // no folder id, get owners's files' root + let files = self + .file_read + .list_files_for_owner(folder_id, owner_id) + .await?; + Ok(files.into_iter().map(FileDto::from).collect()) + } } async fn get_file_stream( @@ -275,7 +304,7 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_stream(id).await } - async fn get_file_stream_owned( + async fn get_file_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -297,7 +326,7 @@ impl FileRetrievalUseCase for FileRetrievalService { .await } - async fn get_file_optimized_owned( + async fn get_file_optimized_with_perms( &self, id: &str, caller_id: Uuid, @@ -333,7 +362,7 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_range_stream(id, start, end).await } - async fn get_file_range_stream_owned( + async fn get_file_range_stream_with_perms( &self, id: &str, caller_id: Uuid, @@ -344,6 +373,7 @@ impl FileRetrievalUseCase for FileRetrievalService { self.file_read.get_file_range_stream(id, start, end).await } + // TODO: check: no permission check async fn stream_files_in_subtree( &self, folder_id: &str, @@ -366,13 +396,24 @@ impl FileRetrievalUseCase for FileRetrievalService { Ok(files.into_iter().map(FileDto::from).collect()) } - async fn list_files_batch_for_owner( + async fn list_files_batch_with_perms( &self, folder_id: Option<&str>, owner_id: Uuid, offset: i64, limit: i64, ) -> Result, DomainError> { + if folder_id.is_some() { + // folder id is defined, check permissions + self.require_target_folder_perm(folder_id, Permission::Read, owner_id) + .await?; + let files = self + .file_read + .list_files_batch(folder_id, offset, limit) + .await?; + return Ok(files.into_iter().map(FileDto::from).collect()); + } + let files = self .file_read .list_files_batch_for_owner(folder_id, owner_id, offset, limit) diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 0a3fc2df..03ba62ac 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -41,6 +41,14 @@ impl FolderService { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { + async fn has_permission( + &self, + _caller_id: Uuid, + _permission: Permission, + _folder_id: &str, + ) -> Result<(), DomainError> { + Ok(()) + } async fn create_folder_with_perms( &self, _dto: CreateFolderDto, @@ -72,7 +80,7 @@ impl FolderService { Ok(vec![]) } - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -98,7 +106,7 @@ impl FolderService { ) } - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -157,6 +165,28 @@ impl FolderService { } impl FolderUseCase for FolderService { + /// Verifies the caller has the given permition on a resource + /// `folder_id`. `None` is the caller's root namespace and always allowed. + /// + /// Returns `Ok(())` when permitted, `DomainError::not_found(...)` when not + /// (anti-enumeration — same error as "folder doesn't exist"). + /// + /// Used by handlers that need a fail-fast pre-check BEFORE spooling + /// large request bodies (file upload, chunked upload). The authoritative + /// check happens again inside the upload/management services before any + /// DB write — this is a UX/resource optimization, not a security boundary. + async fn has_permission( + &self, + caller_id: Uuid, + permission: Permission, + folder_id: &str, + ) -> Result<(), DomainError> { + let resource = Self::folder_resource(folder_id)?; + self.authz + .require(Subject::User(caller_id), permission, resource) + .await + } + /// Creates a new folder async fn create_folder_with_perms( &self, @@ -271,6 +301,7 @@ impl FolderUseCase for FolderService { .list_folders(parent_id) .await .map_err(|e| { + tracing::warn!("errror while fetching folders {}", e); DomainError::internal_error( "FolderStorage", format!("Failed to list folders in parent: {:?}: {}", parent_id, e), @@ -283,59 +314,77 @@ impl FolderUseCase for FolderService { /// Lists folders scoped to a specific owner. /// Self-healing: if listing root folders and none exist, creates a home folder. - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, parent_id: Option<&str>, - owner_id: Uuid, + caller_id: Uuid, ) -> Result, DomainError> { - let folders = self - .folder_storage - .list_folders_by_owner(parent_id, owner_id) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!( - "Failed to list folders for owner '{}' in parent {:?}: {}", - owner_id, parent_id, e - ), + if let Some(parent_id_unwrapped) = parent_id { + // check authorisation + self.authz + .require( + Subject::User(caller_id), + Permission::Read, + Self::folder_resource(parent_id_unwrapped)?, ) - })?; - - // Self-healing: if listing root folders and none exist, create a home folder - // This ensures the frontend always gets a valid userHomeFolderId - if parent_id.is_none() && folders.is_empty() { - tracing::info!( - "No root folders found for user {}, creating home folder automatically", - owner_id - ); - let owner_id_short = { - let s = owner_id.to_string(); - s[..8.min(s.len())].to_string() - }; - let folder_name = format!("My Folder - {}", owner_id_short); - match self + .await?; + return self.list_folders(parent_id).await; + } else { + // No parent defined grab user's homes + let folders = self .folder_storage - .create_home_folder(owner_id, folder_name.clone()) + .list_folders_by_owner(parent_id, caller_id) .await - { - Ok(home_folder) => { - tracing::info!( - "Created home folder '{}' for user {}", - folder_name, - owner_id - ); - return Ok(vec![FolderDto::from(home_folder)]); - } - Err(e) => { - tracing::warn!("Failed to create home folder for user {}: {}", owner_id, e); - // Return empty list rather than failing - user might not have storage quota, etc. + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!( + "Failed to list folders for owner '{}' in parent {:?}: {}", + caller_id, parent_id, e + ), + ) + })?; + + if folders.is_empty() { + // Self-healing: if listing root folders and none exist, create a home folder + // This ensures the frontend always gets a valid userHomeFolderId + tracing::info!( + "No root folders found for user {}, creating home folder automatically", + caller_id + ); + let owner_id_short = { + let s = caller_id.to_string(); + s[..8.min(s.len())].to_string() + }; + // TODO: what about i18n ? + let folder_name = format!("My Folder - {}", owner_id_short); + match self + .folder_storage + .create_home_folder(caller_id, folder_name.clone()) + .await + { + Ok(home_folder) => { + tracing::info!( + "Created home folder '{}' for user {}", + folder_name, + caller_id + ); + return Ok(vec![FolderDto::from(home_folder)]); + } + Err(e) => { + tracing::warn!( + "Failed to create home folder for user {}: {}", + caller_id, + e + ); + // Return empty list rather than failing - user might not have storage quota, etc. + } } } + Ok(folders.into_iter().map(FolderDto::from).collect()) } - - Ok(folders.into_iter().map(FolderDto::from).collect()) } + // TODO: move self healing in other part (on account creation on or login ?) /// Lists folders with pagination async fn list_folders_paginated( @@ -373,7 +422,7 @@ impl FolderUseCase for FolderService { } /// Lists folders with pagination, scoped to a specific owner. - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, parent_id: Option<&str>, owner_id: Uuid, @@ -382,7 +431,17 @@ impl FolderUseCase for FolderService { { let pagination = pagination.validate_and_adjust(); - let (folders, total_items) = self + if let Some(parent_id_unwrapped) = parent_id { + self.authz + .require( + Subject::User(owner_id), + Permission::Read, + Self::folder_resource(parent_id_unwrapped)?, + ) + .await?; + return self.list_folders_paginated(parent_id, &pagination).await; + } else { + let (folders, total_items) = self .folder_storage .list_folders_by_owner_paginated( parent_id, @@ -402,16 +461,17 @@ impl FolderUseCase for FolderService { ) })?; - let total = total_items.unwrap_or(folders.len()); + let total = total_items.unwrap_or(folders.len()); - let response = crate::application::dtos::pagination::PaginatedResponseDto::new( - folders.into_iter().map(FolderDto::from).collect(), - pagination.page, - pagination.page_size, - total, - ); + let response = crate::application::dtos::pagination::PaginatedResponseDto::new( + folders.into_iter().map(FolderDto::from).collect(), + pagination.page, + pagination.page_size, + total, + ); - Ok(response) + Ok(response) + } } /// Renames a folder after verifying the caller has `Update` permission. diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 0a36815f..e143bc5c 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -387,7 +387,7 @@ use crate::common::stubs::StubFileRetrievalUseCase; async fn stub_get_file_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileRetrievalUseCase; - let result = stub.get_file_owned("file-1", user_id).await; + let result = stub.get_file_with_perms("file-1", user_id).await; assert!(result.is_ok(), "stub should return Ok for get_file_owned"); } @@ -396,7 +396,7 @@ async fn stub_get_file_optimized_owned_returns_ok() { let user_id = Uuid::new_v4(); let stub = StubFileRetrievalUseCase; let result = stub - .get_file_optimized_owned("file-1", user_id, true, false) + .get_file_optimized_with_perms("file-1", user_id, true, false) .await; assert!( result.is_ok(), diff --git a/src/application/services/share_browse_service.rs b/src/application/services/share_browse_service.rs index fdd2ecf5..64f0bd92 100644 --- a/src/application/services/share_browse_service.rs +++ b/src/application/services/share_browse_service.rs @@ -179,9 +179,9 @@ impl ShareBrowseService { ) -> Result { let (folders_res, files_res) = tokio::join!( self.folder_service - .list_folders_for_owner(Some(parent_folder_id), owner_id), + .list_folders_with_perms(Some(parent_folder_id), owner_id), self.file_retrieval - .list_files_owned(Some(parent_folder_id), owner_id), + .list_files_with_perms(Some(parent_folder_id), owner_id), ); Ok(FolderListingDto { folders: folders_res?, diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 95249994..0980da6e 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -6,18 +6,21 @@ use crate::application::dtos::display_helpers::{ category_for, icon_class_for, icon_special_class_for, }; use crate::application::dtos::trash_dto::TrashedItemDto; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::{DomainError, ErrorKind, Result}; use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; use crate::domain::repositories::folder_repository::FolderRepository; use crate::domain::repositories::trash_repository::TrashRepository; +use crate::domain::services::authorization::{Permission, Resource, Subject}; 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::repositories::pg::trash_db_repository::TrashDbRepository; use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::file_content_cache::FileContentCache; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::thumbnail_service::ThumbnailService; /** @@ -56,6 +59,9 @@ pub struct TrashService { /// Content cache — invalidated when files are permanently deleted from trash. content_cache: Option>, + /// Authz engine + authz: Arc, + /// Number of days items should be kept in trash before automatic cleanup retention_days: u32, } @@ -71,6 +77,7 @@ impl TrashService { dedup_service: Arc, thumbnail_service: Option>, content_cache: Option>, + authz: Arc, ) -> Self { Self { trash_repository, @@ -80,6 +87,7 @@ impl TrashService { dedup_service, thumbnail_service, content_cache, + authz, retention_days, } } @@ -176,6 +184,7 @@ impl TrashUseCase for TrashService { Ok(dtos) } + // TODO: change item_type into Resource enum #[instrument(skip(self))] async fn move_to_trash(&self, item_id: &str, item_type: &str, user_id: Uuid) -> Result<()> { info!( @@ -209,10 +218,23 @@ impl TrashUseCase for TrashService { "file" => { info!("Processing file to move to trash: {}", item_id); + // XXX: right now only owner can move to trash, need to improve + // Get the file — ownership-verified at SQL level. // Returns NotFound if the file does not exist OR belongs to // another user, preventing cross-user trash operations. debug!("Getting file data (owner-scoped): {}", item_id); + + let file_id = Uuid::parse_str(item_id) + .map_err(|_| DomainError::not_found("File", item_id))?; + self.authz + .require( + Subject::User(user_id), + Permission::Delete, + Resource::File(file_id), + ) + .await?; + let file = match self .file_read_port .get_file_for_owner(item_id, user_id) @@ -236,6 +258,7 @@ impl TrashUseCase for TrashService { debug!("Original file path: {}", original_path); // Create the trash item + // FIXME: item will be created with user_id that mat not be the owner_id debug!("Creating TrashedItem object for the file"); let trashed_item = TrashedItem::new( item_uuid, @@ -286,6 +309,17 @@ impl TrashUseCase for TrashService { Ok(()) } "folder" => { + // check deletion permition + let folder_id = Uuid::parse_str(item_id) + .map_err(|_| DomainError::not_found("Folder", item_id))?; + self.authz + .require( + Subject::User(user_id), + Permission::Delete, + Resource::Folder(folder_id), + ) + .await?; + // Get the folder and verify ownership. // Returns NotFound if the folder does not exist or belongs // to another user — prevents cross-user trash operations. @@ -301,18 +335,10 @@ impl TrashUseCase for TrashService { ) })?; - // Ownership check — return NotFound (not Forbidden) to - // prevent leaking whether the folder exists. - if folder.owner_id() != Some(user_id) { - return Err(DomainError::not_found( - "Folder", - format!("Folder not found: {}", item_id), - )); - } - let original_path = folder.storage_path().to_string(); // Create the trash item + // FIXME: item will be created with user_id that mat not be the owner_id let trashed_item = TrashedItem::new( item_uuid, user_uuid, diff --git a/src/common/di.rs b/src/common/di.rs index ffabe91c..d44e7baa 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -38,6 +38,7 @@ use crate::infrastructure::services::file_content_cache::{ use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; use crate::infrastructure::services::nextcloud_chunked_upload_service::NextcloudChunkedUploadService; use crate::infrastructure::services::path_service::PathService; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::app_password_service::AppPasswordService; @@ -350,7 +351,7 @@ impl AppServiceFactory { repos: &RepositoryServices, trash_service: Option>, db_pool: &Arc, - authz: &Arc, + authz: &Arc, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new( @@ -452,6 +453,7 @@ impl AppServiceFactory { &self, repos: &RepositoryServices, core: &CoreServices, + authz: &Arc, ) -> Option> { if !self.config.features.enable_trash { tracing::info!("Trash service is disabled in configuration"); @@ -470,6 +472,7 @@ impl AppServiceFactory { core.dedup_service.clone(), Some(core.thumbnail_service.clone()), Some(core.file_content_cache.clone()), + authz.clone(), )); // Initialize cleanup service (bulk-deletes expired items in 2 SQL queries) @@ -609,10 +612,7 @@ impl AppServiceFactory { // 2. Repository services (requires PgPool for all metadata) let repos = self.create_repository_services(&core, &pool); - // 3. Trash service (needed before application services) - let trash_service = self.create_trash_service(&repos, &core).await; - - // 3b. Authorization engine — must exist before application services + // 3a. Authorization engine — must exist before application services // because services hold an Arc for ReBAC checks. let authorization = build_authorization_engine( pool.clone(), @@ -620,6 +620,11 @@ impl AppServiceFactory { repos.file_read_repository.clone(), ); + // 3b. Trash service (needed before application services) + let trash_service = self + .create_trash_service(&repos, &core, &authorization) + .await; + // 4. Application services (with trash + authz already wired) let mut apps = self.create_application_services( &core, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index dd944da2..ca4972a3 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -34,6 +34,7 @@ use crate::common::errors::DomainError; use crate::domain::entities::file::File; use crate::domain::entities::folder::Folder; use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::authorization::Permission; use crate::domain::services::i18n_service::{I18nResult, I18nService, Locale}; use crate::domain::services::path_service::StoragePath; @@ -355,6 +356,15 @@ impl I18nService for StubI18nService { pub struct StubFolderUseCase; impl FolderUseCase for StubFolderUseCase { + async fn has_permission( + &self, + _caller_id: Uuid, + _permission: Permission, + _file_id: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn create_folder_with_perms( &self, _dto: CreateFolderDto, @@ -383,7 +393,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(Vec::new()) } - async fn list_folders_for_owner( + async fn list_folders_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -399,7 +409,7 @@ impl FolderUseCase for StubFolderUseCase { Ok(PaginatedResponseDto::new(Vec::new(), 0, 10, 0)) } - async fn list_folders_for_owner_paginated( + async fn list_folders_paginated_with_perms( &self, _parent_id: Option<&str>, _owner_id: Uuid, @@ -521,7 +531,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Vec::new()) } - async fn list_files_owned( + async fn list_files_with_perms( &self, _folder_id: Option<&str>, _owner_id: Uuid, @@ -537,7 +547,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::new(empty_stream)) } - async fn get_file_stream_owned( + async fn get_file_stream_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -583,11 +593,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::pin(futures::stream::empty())) } - async fn get_file_owned(&self, _id: &str, _caller_id: Uuid) -> Result { + async fn get_file_with_perms( + &self, + _id: &str, + _caller_id: Uuid, + ) -> Result { Ok(FileDto::default()) } - async fn get_file_optimized_owned( + async fn get_file_optimized_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -604,7 +618,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { )) } - async fn get_file_range_stream_owned( + async fn get_file_range_stream_with_perms( &self, _id: &str, _caller_id: Uuid, @@ -623,6 +637,15 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { pub struct StubFileManagementUseCase; impl FileManagementUseCase for StubFileManagementUseCase { + async fn has_permission( + &self, + _caller_id: Uuid, + _permission: Permission, + _file_id: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn copy_file_with_perms( &self, _file_id: &str, diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 98036d81..42a03f02 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -5,6 +5,7 @@ //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! maps them to / from `storage.access_grants` rows. +use std::fmt; use uuid::Uuid; // ════════════════════════════════════════════════════════════════════════════ @@ -60,6 +61,12 @@ impl Subject { } } +impl fmt::Display for Subject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", self.type_str(), self.id()) + } +} + // ════════════════════════════════════════════════════════════════════════════ // Resource — what the permission is on // ════════════════════════════════════════════════════════════════════════════ @@ -68,6 +75,12 @@ impl Subject { pub enum Resource { Folder(Uuid), File(Uuid), + // Reserved for future use: + // Calendar(Uuid), + // Reserved for future use: + // AddressBook(Uuid), + // Reserved for future use: + // Playlist(Uuid), } impl Resource { @@ -75,12 +88,20 @@ impl Resource { match self { Resource::Folder(_) => "folder", Resource::File(_) => "file", + //Resource::Calendar(_) => "calendar", + //Resource::AddressBook(_) => "adressbook", + //Resource::Playlist(_) => "playlist", } } pub fn id(&self) -> Uuid { match self { - Resource::Folder(id) | Resource::File(id) => *id, + Resource::Folder(id) + | Resource::File(id) + //| Resource::Calendar(id) + //| Resource::AddressBook(id) + //| Resource::Playlist(id) + => *id, } } @@ -88,11 +109,20 @@ impl Resource { match resource_type { "folder" => Some(Resource::Folder(id)), "file" => Some(Resource::File(id)), + //"calendar" => Some(Resource::Calendar(id)), + //"adressbook" => Some(Resource::AddressBook(id)), + //"playlist" => Some(Resource::Playlist(id)), _ => None, } } } +impl fmt::Display for Resource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", self.type_str(), self.id()) + } +} + // ════════════════════════════════════════════════════════════════════════════ // Permission — what action is allowed // ════════════════════════════════════════════════════════════════════════════ @@ -151,6 +181,12 @@ impl Permission { } } +impl fmt::Display for Permission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + // ════════════════════════════════════════════════════════════════════════════ // Grant — a row in storage.access_grants // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 61c14209..8a5b8b44 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -90,6 +90,8 @@ impl ChunkedUploadHandler { /// "expires_at": 86400 /// } /// ``` + /// TODO: how is implemented security (owneship, permission ?) + /// current caveat: upload can start without know is path permits upload pub(super) async fn create_upload_impl( State(state): State>, auth_user: AuthUser, @@ -264,6 +266,7 @@ impl ChunkedUploadHandler { /// POST /api/uploads/:upload_id/complete - Finalize upload /// /// Assembles all chunks into the final file and creates the file record + // TODO: how is implemented security (owneship, permission ?) pub(super) async fn complete_upload_impl( State(state): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 0286a5b4..4ffbac29 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -11,17 +11,17 @@ use serde::Deserialize; use std::collections::HashMap; use utoipa::ToSchema; -use crate::application::dtos::file_dto::FileDto; -use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; +use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase}; use crate::common::di::AppState; use crate::infrastructure::services::audio_metadata_service::AudioMetadataService; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; +use crate::{application::dtos::file_dto::FileDto, domain::services::authorization::Permission}; use std::sync::Arc; /** @@ -85,6 +85,7 @@ impl FileHandler { tracing::debug!("📤 Processing streaming file upload (hash-on-write)"); + // caveat: if folder_id field is given after check can fails while let Some(field) = multipart.next_field().await.unwrap_or(None) { let name = field.name().unwrap_or("").to_string(); @@ -115,24 +116,24 @@ impl FileHandler { .unwrap_or("application/octet-stream") .to_string(); - // ── SECURITY: Verify folder ownership before upload (IDOR V-03 fix) ── - if let Some(ref fid) = folder_id { - use crate::application::ports::folder_ports::FolderUseCase; - let folder_service = &state.applications.folder_service; - if folder_service - .get_folder_with_perms(fid, auth_user.id) + // ── Fail-fast pre-check: verify the caller can Create inside + // the target folder BEFORE spooling the multipart body to disk. + // The upload service re-checks at write time — this is a + // UX/resource optimization, not the security boundary. + if let Some(ref fid) = folder_id + && let Err(err) = state + .applications + .folder_service_concrete + .has_permission(auth_user.id, Permission::Create, fid) .await - .is_err() - { - tracing::warn!( - "⛔ UPLOAD REJECTED (IDOR): user='{}' attempted upload to folder '{}' owned by another user", - auth_user.username, - fid, - ); - return Err(Self::domain_error_response( - crate::common::errors::DomainError::not_found("Folder", fid), - )); - } + { + tracing::warn!( + "⛔ UPLOAD REJECTED: user='{}' folder='{}' err='{}'", + auth_user.username, + fid, + err + ); + return Err(Self::domain_error_response(err)); } // ── Early quota check (before spooling to disk) ────── @@ -328,6 +329,16 @@ impl FileHandler { ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; + // check first that user can access this resource + if let Err(err) = state + .applications + .file_management_service + .has_permission(auth_user.id, Permission::Read, &id) + .await + { + return AppError::from(err).into_response(); + } + let thumbnail_service = &state.core.thumbnail_service; let thumb_size = match size.as_str() { @@ -385,7 +396,7 @@ impl FileHandler { let file_retrieval_service = &state.applications.file_retrieval_service; let file = match file_retrieval_service - .get_file_owned(&id, auth_user.id) + .get_file_with_perms(&id, auth_user.id) .await { Ok(f) => f, @@ -478,6 +489,16 @@ impl FileHandler { ) -> impl IntoResponse { use crate::application::ports::thumbnail_ports::ThumbnailSize; + // check first that user can access this resource + if let Err(err) = state + .applications + .file_management_service + .has_permission(auth_user.id, Permission::Update, &id) + .await + { + return AppError::from(err).into_response(); + } + let thumbnail_service = &state.core.thumbnail_service; // Validate size @@ -508,7 +529,7 @@ impl FileHandler { // Validate file ownership let file_retrieval_service = &state.applications.file_retrieval_service; if let Err(err) = file_retrieval_service - .get_file_owned(&id, auth_user.id) + .get_file_with_perms(&id, auth_user.id) .await { return AppError::from(err).into_response(); @@ -545,7 +566,7 @@ impl FileHandler { let retrieval = &state.applications.file_retrieval_service; // ── Get file metadata (ownership-scoped) ──────────────────────── - let file_dto = match retrieval.get_file_owned(&id, auth_user.id).await { + let file_dto = match retrieval.get_file_with_perms(&id, auth_user.id).await { Ok(f) => f, Err(err) => { return AppError::from(err).into_response(); @@ -603,7 +624,7 @@ impl FileHandler { Self::content_disposition(&file_dto.name, &file_dto.mime_type, ¶ms); match retrieval - .get_file_range_stream_owned(&id, auth_user.id, start, Some(end + 1)) + .get_file_range_stream_with_perms(&id, auth_user.id, start, Some(end + 1)) .await { Ok(stream) => { @@ -713,7 +734,10 @@ impl FileHandler { tracing::info!("API: Listing files with folder_id: {:?}", folder_id); let retrieval = &state.applications.file_retrieval_service; - match retrieval.list_files_owned(folder_id, auth_user.id).await { + match retrieval + .list_files_with_perms(folder_id, auth_user.id) + .await + { Ok(files) => { // Compute lightweight ETag from max modified_at + count let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0); @@ -751,6 +775,7 @@ impl FileHandler { /// Delegates to [`Self::upload_file_inner`] and, on success, spawns /// a background task to generate all thumbnail sizes before serialising /// the `FileDto` once. + /// TODO: should move thumbnail generation to a generic hook ? (onfileUploaded, other services will beneficiate it) pub(super) async fn upload_file_with_thumbnails_impl( State(state): State, auth_user: AuthUser, @@ -797,6 +822,7 @@ impl FileHandler { }); } + // TODO: same remark: a hook to handle easily audio service // Extract audio metadata for supported audio files in background. if let Some(ref audio_service) = state.applications.audio_metadata_service && AudioMetadataService::is_audio_file(&file.mime_type) @@ -825,15 +851,14 @@ impl FileHandler { auth_user: AuthUser, Path(file_id): Path, ) -> impl IntoResponse { - // Verify ownership - let file_read = &state.repositories.file_read_repository; - if let Err(e) = file_read.verify_file_owner(&file_id, auth_user.id).await { - let msg = e.to_string(); - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": msg })), - ) - .into_response(); + // check first that user can access this resource + if let Err(err) = state + .applications + .file_management_service + .has_permission(auth_user.id, Permission::Read, &file_id) + .await + { + return AppError::from(err).into_response(); } let metadata_repo = &state.repositories.file_metadata_repository; @@ -927,6 +952,7 @@ impl FileHandler { } /// Moves a file to a different folder (ownership-verified) + /// TODO: dead function ? pub async fn move_file( State(state): State, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index bf757ce3..370722be 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -51,7 +51,7 @@ impl FolderHandler { "create_folder: parent_id is None for user '{}', resolving home folder", auth_user.username ); - match service.list_folders_for_owner(None, auth_user.id).await { + match service.list_folders_with_perms(None, auth_user.id).await { Ok(folders) => { if let Some(home) = folders.first() { tracing::info!( @@ -89,22 +89,8 @@ impl FolderHandler { auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { - match service.get_folder(&id).await { - Ok(folder) => { - // Access check: folder must belong to the requesting user - if let Some(ref owner) = folder.owner_id - && owner != &auth_user.id.to_string() - { - tracing::warn!( - "get_folder: user '{}' attempted to access folder '{}' owned by '{}'", - auth_user.id, - id, - owner - ); - return AppError::not_found("Folder not found").into_response(); - } - (StatusCode::OK, Json(folder)).into_response() - } + match service.get_folder_with_perms(&id, auth_user.id).await { + Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Err(err) => AppError::from(err).into_response(), } } @@ -146,7 +132,7 @@ impl FolderHandler { pagination: Query, ) -> axum::response::Response { match service - .list_folders_for_owner_paginated(Some(&id), auth_user.id, &pagination) + .list_folders_paginated_with_perms(Some(&id), auth_user.id, &pagination) .await { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), @@ -163,7 +149,7 @@ impl FolderHandler { auth_user: &AuthUser, ) -> axum::response::Response { match service - .list_folders_for_owner(parent_id, auth_user.id) + .list_folders_with_perms(parent_id, auth_user.id) .await { Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), @@ -206,8 +192,8 @@ impl FolderHandler { // Run both queries concurrently — no sequential wait. let (folders_result, files_result) = tokio::join!( - folder_service.list_folders_for_owner(Some(&id), auth_user.id), - file_service.list_files_owned(Some(&id), auth_user.id) + folder_service.list_folders_with_perms(Some(&id), auth_user.id), + file_service.list_files_with_perms(Some(&id), auth_user.id) ); match (folders_result, files_result) { @@ -226,7 +212,6 @@ impl FolderHandler { .unwrap() .into_response(); } - let listing = FolderListingDto { folders, files }; let mut resp = (StatusCode::OK, Json(listing)).into_response(); resp.headers_mut() @@ -286,6 +271,7 @@ impl FolderHandler { ) -> impl IntoResponse { let user_id = auth_user.id; // Check if trash service is available + // FIXME: permissions !! if let Some(trash_service) = &state.trash_service { tracing::info!("Moving folder to trash: {}", id); @@ -328,22 +314,11 @@ impl FolderHandler { // Get folder information and verify ownership let folder_service = &state.applications.folder_service; - match folder_service.get_folder(&id).await { + match folder_service + .get_folder_with_perms(&id, auth_user.id) + .await + { Ok(folder) => { - // Access check: folder must belong to the requesting user - if folder.owner_id.as_deref() != Some(&auth_user.id.to_string()) { - tracing::warn!( - "download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'", - auth_user.id, - id, - folder.owner_id - ); - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ "error": "Folder not found" })), - ) - .into_response(); - } tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); // Use ZIP service from DI container diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index be709244..2cc97852 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -561,6 +561,7 @@ fn share_browse_error_response(err: crate::common::errors::DomainError) -> Respo AppError::from(err).into_response() } +// TODO: remove this and use the classic /api/files & /api/folders get, but with the token as session ? #[utoipa::path( get, path = "/api/s/{token}/contents", diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 5609675d..9d91f329 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -189,7 +189,7 @@ async fn handle_webdav_methods( async fn resolve_webdav_path(state: &Arc, user_id: Uuid, path: &str) -> Option { let folder_service = &state.applications.folder_service; let home_folders = folder_service - .list_folders_for_owner(None, user_id) + .list_folders_with_perms(None, user_id) .await .ok()?; let home = home_folders.first()?; @@ -514,7 +514,7 @@ async fn build_streaming_propfind_response( page_size: pagination.page_size, }; let result = folder_service - .list_folders_for_owner_paginated(fid_ref, user_id, &pag) + .list_folders_paginated_with_perms(fid_ref, user_id, &pag) .await .map_err(|e| std::io::Error::other(e.to_string()))?; @@ -544,7 +544,7 @@ async fn build_streaming_propfind_response( let mut offset: i64 = 0; loop { let batch: Vec = file_retrieval_service - .list_files_batch_for_owner(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE) + .list_files_batch_with_perms(fid_ref, user_id, offset, PROPFIND_BATCH_SIZE) .await .map_err(|e| std::io::Error::other(e.to_string()))?; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index fc5cd595..80ed7b81 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -401,7 +401,7 @@ async fn authorize_wopi_access( requested_action: &str, ) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> { let file = file_retrieval - .get_file_owned(file_id, caller_id) + .get_file_with_perms(file_id, caller_id) .await .map_err(|_| StatusCode::NOT_FOUND)?; // Owner verified — grant write unless explicitly requesting view-only. diff --git a/tests/common/server.env b/tests/common/server.env index 717ab4dc..b8f142f5 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -17,6 +17,7 @@ OXICLOUD_WOPI_ENABLED=false OXICLOUD_OIDC_ENABLED=false RUST_LOG=warn #RUST_LOG=debug +#RUST_LOG=info # grow up limits for tests OXICLOUD_RATE_LIMIT_REFRESH_MAX=120 diff --git a/tests/webdav/common.sh b/tests/webdav/common.sh index 7dada61c..a5149a0b 100644 --- a/tests/webdav/common.sh +++ b/tests/webdav/common.sh @@ -1,6 +1,9 @@ #!/bin/bash -source test.env +if [ -z "$base_url" ] +then + source test.env +fi err() { echo "$*" >&2 @@ -32,7 +35,10 @@ oxicloud_setup() { # returns TOKEN variable oxicloud_login() { - oxicloud_setup + if [[ ( $# -eq 0 ) || ( "$1" != "no-create" ) ]] + then + oxicloud_setup + fi LOGIN_DATA='{"username":"'$username'","password":"'$password'"}' From bd1b17b58987ac3f33ee2a8b87b0aa52d1358871 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 18:35:50 +0200 Subject: [PATCH 07/13] test(grants): full coverate of /api/files and /api/folders --- tests/api/grants.hurl | 401 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index d5378479..4bd1042c 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -301,3 +301,404 @@ Authorization: Bearer {{eve_token}} HTTP 200 [Asserts] jsonpath "$" count == 0 + + +# ════════════════════════════════════════════════════════════════════ +# PHASE 2 — Comprehensive permission coverage with fresh user "adam". +# ════════════════════════════════════════════════════════════════════ +# Exercises every engine-aware endpoint at each permission tier: +# +# no grant → all read/write/delete operations return 404 +# Viewer → read endpoints OK, modify/delete endpoints return 404 +# Editor → update + create + thumbnail-push OK, delete still 404 +# Admin → everything including delete +# +# Endpoints in scope (all routed through the AuthorizationEngine): +# Folders: /contents · /contents/paginated · /listing · /download (zip) +# · POST / · PUT /{id}/rename · PUT /{id}/move · DELETE /{id} +# Files: GET / · GET /{id} (download) +# · GET /{id}/metadata · GET /{id}/thumbnail/{size} +# · PUT /{id}/thumbnail/{size} (push, Update) +# · PUT /{id}/rename · PUT /{id}/move · DELETE /{id} +# · POST /upload (via folder has_permission) +# +# Listing endpoints that are still owner-scoped (GET /api/folders root, +# GET /api/folders/paginated) are NOT covered here — they don't +# reflect grants today and are tracked as separate cleanup work. + + +# ───────────────────────────────────────────────────────────── +# Step 20 — Create user adam (fresh, no relationship to alice's tree). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "username": "adam", "password": "AdamPassword1!", "email": "adam@example.com", "role": "user" } + +HTTP 201 +[Captures] +adam_user_id: jsonpath "$.id" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "adam", "password": "AdamPassword1!" } + +HTTP 200 +[Captures] +adam_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 21 — Alice creates a fresh shareable folder, sub-folder, and +# uploads a JPEG (which the server auto-thumbnails). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "perm-test-folder", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +perm_folder_id: jsonpath "$.id" + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "perm-test-child", "parent_id": "{{perm_folder_id}}" } + +HTTP 201 +[Captures] +perm_child_id: jsonpath "$.id" + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{alice_token}} +[MultipartFormData] +folder_id: {{perm_folder_id}} +file: file,fixtures/oxicloud-logo.jpg; image/jpeg + +HTTP 201 +[Captures] +perm_file_id: jsonpath "$.id" + + +# ════════════════════════════════════════════════════════════════════ +# Phase 2A — Adam has NO grant. Every engine-aware endpoint denies. +# ════════════════════════════════════════════════════════════════════ + +# ── Folder reads ───────────────────────────────────────────── +GET {{base_url}}/api/folders/{{perm_folder_id}}/contents +Authorization: Bearer {{adam_token}} + +HTTP 404 + +GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated +Authorization: Bearer {{adam_token}} + +HTTP 404 + +GET {{base_url}}/api/folders/{{perm_folder_id}}/listing +Authorization: Bearer {{adam_token}} + +HTTP 404 + +GET {{base_url}}/api/folders/{{perm_folder_id}}/download +Authorization: Bearer {{adam_token}} + +HTTP 404 + +# ── File reads ─────────────────────────────────────────────── +GET {{base_url}}/api/files?folder_id={{perm_folder_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + +GET {{base_url}}/api/files/{{perm_file_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + +GET {{base_url}}/api/files/{{perm_file_id}}/metadata +Authorization: Bearer {{adam_token}} + +HTTP 404 + +GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon +Authorization: Bearer {{adam_token}} + +HTTP 404 + +# ── Folder mutations ───────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-attack", "parent_id": "{{perm_folder_id}}" } + +HTTP 404 + +PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-rename-attempt" } + +HTTP 404 + +DELETE {{base_url}}/api/folders/{{perm_folder_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + +# ── File mutations ─────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{adam_token}} +[MultipartFormData] +folder_id: {{perm_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + +PUT {{base_url}}/api/files/{{perm_file_id}}/rename +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-file-rename" } + +HTTP 404 + +PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon +Authorization: Bearer {{adam_token}} +Content-Type: image/png +file,fixtures/blue-image.png; + +HTTP 404 + +DELETE {{base_url}}/api/files/{{perm_file_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 2B — Alice grants adam Viewer. Read OK, mutate/delete denied. +# ════════════════════════════════════════════════════════════════════ +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{adam_user_id}}" }, + "resource": { "type": "folder", "id": "{{perm_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 + +# ── Read endpoints now succeed ────────────────────────────── +GET {{base_url}}/api/folders/{{perm_folder_id}}/contents +Authorization: Bearer {{adam_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].id" == "{{perm_child_id}}" + +GET {{base_url}}/api/folders/{{perm_folder_id}}/contents/paginated +Authorization: Bearer {{adam_token}} + +HTTP 200 + +GET {{base_url}}/api/folders/{{perm_folder_id}}/listing +Authorization: Bearer {{adam_token}} + +HTTP 200 + +GET {{base_url}}/api/folders/{{perm_folder_id}}/download +Authorization: Bearer {{adam_token}} + +HTTP 200 +[Asserts] +header "Content-Type" contains "zip" + +GET {{base_url}}/api/files?folder_id={{perm_folder_id}} +Authorization: Bearer {{adam_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].id" == "{{perm_file_id}}" + +GET {{base_url}}/api/files/{{perm_file_id}} +Authorization: Bearer {{adam_token}} + +HTTP 200 + +GET {{base_url}}/api/files/{{perm_file_id}}/metadata +Authorization: Bearer {{adam_token}} + +HTTP 200 + +GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon +Authorization: Bearer {{adam_token}} + +HTTP 200 +[Asserts] +header "Content-Type" startsWith "image/" + +# ── Cascading: child folder also readable via parent's grant ─ +GET {{base_url}}/api/folders/{{perm_child_id}}/contents +Authorization: Bearer {{adam_token}} + +HTTP 200 + +# ── Mutations still denied (Viewer has no Update/Create/Delete) ─ +POST {{base_url}}/api/folders +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-attack-2", "parent_id": "{{perm_folder_id}}" } + +HTTP 404 + +PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-rename-as-viewer" } + +HTTP 404 + +PUT {{base_url}}/api/files/{{perm_file_id}}/rename +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-file-rename-as-viewer" } + +HTTP 404 + +PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon +Authorization: Bearer {{adam_token}} +Content-Type: image/png +file,fixtures/blue-image.png; + +HTTP 404 + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{adam_token}} +[MultipartFormData] +folder_id: {{perm_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + +DELETE {{base_url}}/api/files/{{perm_file_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + +DELETE {{base_url}}/api/folders/{{perm_folder_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 2C — Promote adam to Editor (read + comment + create + update). +# Create + Update endpoints now succeed; Delete still denied. +# ════════════════════════════════════════════════════════════════════ +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{adam_user_id}}" }, + "resource": { "type": "folder", "id": "{{perm_folder_id}}" }, + "role": "editor" +} + +HTTP 200 + +# ── Update succeeds ───────────────────────────────────────── +PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "renamed-by-adam-as-editor" } + +HTTP 200 + +PUT {{base_url}}/api/files/{{perm_file_id}}/rename +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-renamed-logo.jpg" } + +HTTP 200 + +# ── Thumbnail push (Update) succeeds ──────────────────────── +PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview +Authorization: Bearer {{adam_token}} +Content-Type: image/png +file,fixtures/blue-image.png; + +HTTP 201 + +# ── Create succeeds ───────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ "name": "adam-created-child", "parent_id": "{{perm_folder_id}}" } + +HTTP 201 + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{adam_token}} +[MultipartFormData] +folder_id: {{perm_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 + +# ── Delete still denied (Editor excludes Delete) ──────────── +DELETE {{base_url}}/api/files/{{perm_file_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + +DELETE {{base_url}}/api/folders/{{perm_folder_id}} +Authorization: Bearer {{adam_token}} + +HTTP 404 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 2D — Promote adam to Admin (all 6 permissions). Delete OK. +# ════════════════════════════════════════════════════════════════════ +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{adam_user_id}}" }, + "resource": { "type": "folder", "id": "{{perm_folder_id}}" }, + "role": "admin" +} + +HTTP 200 + +DELETE {{base_url}}/api/files/{{perm_file_id}} +Authorization: Bearer {{adam_token}} + +HTTP 204 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 2E — Lifecycle cleanup. Alice (still the owner) trashes & +# empties; the trigger removes all access_grants rows. +# ════════════════════════════════════════════════════════════════════ +DELETE {{base_url}}/api/folders/{{perm_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{alice_token}} + +HTTP 200 + +# Adam's incoming list is empty. +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{adam_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 From a53c09f36103c16737e6ff291ddae0de4068a1c8 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 19:11:28 +0200 Subject: [PATCH 08/13] feat(authz): covert and test chunked upload with permissions --- .../services/chunked_upload_service.rs | 15 ++- .../api/handlers/chunked_upload_handler.rs | 29 ++++- tests/api/grants.hurl | 103 ++++++++++++++++++ 3 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/infrastructure/services/chunked_upload_service.rs b/src/infrastructure/services/chunked_upload_service.rs index 127f6584..4f007d81 100644 --- a/src/infrastructure/services/chunked_upload_service.rs +++ b/src/infrastructure/services/chunked_upload_service.rs @@ -770,8 +770,15 @@ impl ChunkedUploadService { } /// Cancel an upload and cleanup — disk I/O outside lock. - async fn cancel_upload_inner(&self, upload_id: &str, user_id: &str) -> Result<(), String> { - self.verify_session_owner(upload_id, user_id)?; + /// + /// Returns: + /// - `DomainError::NotFound` if no session matches `upload_id` for `user_id` + /// (covers both "session missing" and "owned by someone else" — same + /// error for anti-enumeration). + /// - `DomainError::InternalError` for unexpected disk I/O failures. + async fn cancel_upload_inner(&self, upload_id: &str, user_id: &str) -> Result<(), DomainError> { + self.verify_session_owner(upload_id, user_id) + .map_err(|_| DomainError::not_found("Upload", upload_id))?; // Remove from map (~µs) let removed = self.sessions.remove(upload_id).map(|(_, s)| s); @@ -861,9 +868,11 @@ impl ChunkedUploadPort for ChunkedUploadService { } async fn cancel_upload(&self, upload_id: &str, user_id: Uuid) -> Result<(), DomainError> { + // Inner function now returns DomainError with proper variants + // (NotFound for missing/wrong-owner sessions, InternalError otherwise), + // so no mapping needed here. self.cancel_upload_inner(upload_id, &user_id.to_string()) .await - .map_err(|e| DomainError::new(ErrorKind::InternalError, "ChunkedUpload", e)) } fn should_use_chunked(&self, size: u64) -> bool { diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 8a5b8b44..10fbe900 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -21,8 +21,10 @@ use utoipa::ToSchema; use crate::application::ports::chunked_upload_ports::ChunkedUploadPort; use crate::application::ports::chunked_upload_ports::DEFAULT_CHUNK_SIZE; use crate::application::ports::file_ports::FileUploadUseCase; +use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::di::AppState; +use crate::domain::services::authorization::Permission; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -90,8 +92,6 @@ impl ChunkedUploadHandler { /// "expires_at": 86400 /// } /// ``` - /// TODO: how is implemented security (owneship, permission ?) - /// current caveat: upload can start without know is path permits upload pub(super) async fn create_upload_impl( State(state): State>, auth_user: AuthUser, @@ -120,6 +120,27 @@ impl ChunkedUploadHandler { .into_response(); } + // ── Permission pre-check: caller must have Create on the target + // folder BEFORE we allocate a session and accept chunks. The + // upload service re-checks at finalize time, but failing here + // avoids wasting client+server resources on chunks that will be + // rejected. None = caller's root namespace, no check needed. + if let Some(ref fid) = request.folder_id + && let Err(err) = state + .applications + .folder_service_concrete + .has_permission(auth_user.id, Permission::Create, fid) + .await + { + tracing::warn!( + "⛔ CHUNKED UPLOAD REJECTED (no perm): user='{}' folder='{}' err='{}'", + auth_user.username, + fid, + err + ); + return AppError::from(err).into_response(); + } + // ── Quota enforcement ──────────────────────────────────── if let Some(storage_svc) = state.storage_usage_service.as_ref() && let Err(err) = storage_svc @@ -352,9 +373,7 @@ impl ChunkedUploadHandler { .await { Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(e) => { - AppError::internal_error(format!("Failed to cancel upload: {}", e)).into_response() - } + Err(e) => AppError::from(e).into_response(), } } } diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 4bd1042c..adf09960 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -475,6 +475,21 @@ Authorization: Bearer {{adam_token}} HTTP 404 +# ── Chunked upload: cannot start session in alice's folder ── +# create_upload_impl pre-checks Permission::Create via has_permission. +POST {{base_url}}/api/uploads +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ + "filename": "adam-chunked-attack.mp4", + "folder_id": "{{perm_folder_id}}", + "content_type": "video/mp4", + "total_size": 2760653, + "chunk_size": 3000000 +} + +HTTP 404 + # ════════════════════════════════════════════════════════════════════ # Phase 2B — Alice grants adam Viewer. Read OK, mutate/delete denied. @@ -594,6 +609,20 @@ Authorization: Bearer {{adam_token}} HTTP 404 +# ── Viewer cannot start a chunked upload (no Create grant) ── +POST {{base_url}}/api/uploads +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ + "filename": "viewer-chunked-attempt.mp4", + "folder_id": "{{perm_folder_id}}", + "content_type": "video/mp4", + "total_size": 2760653, + "chunk_size": 3000000 +} + +HTTP 404 + # ════════════════════════════════════════════════════════════════════ # Phase 2C — Promote adam to Editor (read + comment + create + update). @@ -649,6 +678,80 @@ file: file,fixtures/hello.txt; text/plain HTTP 201 +# ── Chunked upload full lifecycle as Editor ───────────────── +# 1. Open session (server pre-checks Create on folder) +POST {{base_url}}/api/uploads +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ + "filename": "adam-chunked-video.mp4", + "folder_id": "{{perm_folder_id}}", + "content_type": "video/mp4", + "total_size": 2760653, + "chunk_size": 3000000 +} + +HTTP 201 +[Captures] +adam_upload_id: jsonpath "$.upload_id" + +# 2. Send the single chunk (chunk_size > total_size → 1 chunk). +PATCH {{base_url}}/api/uploads/{{adam_upload_id}}?chunk_index=0 +Authorization: Bearer {{adam_token}} +Content-Type: application/octet-stream +file,fixtures/free_video_over_1MB.mp4; + +HTTP 200 + +# 3. Status query: dave (different user) cannot peek at adam's session. +HEAD {{base_url}}/api/uploads/{{adam_upload_id}} +Authorization: Bearer {{dave_token}} + +HTTP 404 + +# 4. Cancel attempt by a different user is rejected. +DELETE {{base_url}}/api/uploads/{{adam_upload_id}} +Authorization: Bearer {{dave_token}} + +HTTP 404 + +# 5. Adam completes the upload — file is created in alice's folder. +POST {{base_url}}/api/uploads/{{adam_upload_id}}/complete +Authorization: Bearer {{adam_token}} + +HTTP 201 +[Captures] +adam_chunked_file_id: jsonpath "$.file_id" + +# 6. The new file is visible in the folder listing (caller-of-listing is alice). +GET {{base_url}}/api/files?folder_id={{perm_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{adam_chunked_file_id}}')].name" == "adam-chunked-video.mp4" + +# 7. A second session that adam cancels before completing — cleanup path. +POST {{base_url}}/api/uploads +Authorization: Bearer {{adam_token}} +Content-Type: application/json +{ + "filename": "adam-cancelled.mp4", + "folder_id": "{{perm_folder_id}}", + "content_type": "video/mp4", + "total_size": 2760653, + "chunk_size": 3000000 +} + +HTTP 201 +[Captures] +adam_cancel_id: jsonpath "$.upload_id" + +DELETE {{base_url}}/api/uploads/{{adam_cancel_id}} +Authorization: Bearer {{adam_token}} + +HTTP 204 + # ── Delete still denied (Editor excludes Delete) ──────────── DELETE {{base_url}}/api/files/{{perm_file_id}} Authorization: Bearer {{adam_token}} From eb95567a7de24f0bfad90f9cb650b58ccd36658f Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 20:27:25 +0200 Subject: [PATCH 09/13] feat(authz): test & cover batch cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ┌────────────────────────────────┬─────────────────────────────────┬───────────────────────┬─────────────────┬──────────────────────────────┐ │ Endpoint │ Phase 3A no-grant │ Phase 3B Viewer │ Phase 3C Editor │ Phase 3D Admin │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/files/get │ 400 (all failed) │ 200 (2 successful) │ — │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/files/move │ 400 │ 400 (no Update) │ 200 │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/files/copy │ 400 │ — │ 200 │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/files/delete │ 400 │ 400 │ 400 (no Delete) │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/folders/get │ 400 │ 200 │ — │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/folders/create │ 400 │ — │ 201 │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/folders/move │ 400 │ — │ 200 │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/folders/copy │ 400 │ — │ 200 │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/folders/delete │ 400 │ — │ 400 (no Delete) │ 200 │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/trash │ 400 │ — │ — │ 400 (owner-only, documented) │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ POST /api/batch/download │ 404 (NotFound) │ 200 + application/zip │ — │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ GET /api/batch/download?... │ 404 │ 200 + zip │ — │ — │ ├────────────────────────────────┼─────────────────────────────────┼───────────────────────┼─────────────────┼──────────────────────────────┤ │ Phase 3E lifecycle cleanup │ grants table empty after delete │ │ │ │ └────────────────────────────────┴─────────────────────────────────┴───────────────────────┴─────────────────┴──────────────────────────────┘ --- src/application/services/batch_operations.rs | 27 +- src/interfaces/api/handlers/batch_handler.rs | 18 +- tests/api/grants.hurl | 426 +++++++++++++++++++ 3 files changed, 463 insertions(+), 8 deletions(-) diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 921d88b4..2840b474 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -715,6 +715,11 @@ impl BatchOperationService { let buf_writer = BufWriter::with_capacity(256 * 1024, tokio_file); let mut zip = ZipFileWriter::with_tokio(buf_writer); + // Track whether any item was authorized + added to the ZIP. If + // none were, return NotFound — empty ZIPs are useless and mask + // authz failures from the client. + let mut items_added: usize = 0; + // ── Add individual files at the root of the ZIP ────────────────── for file_id in &file_ids { match self @@ -723,11 +728,14 @@ impl BatchOperationService { .await { Ok(file_dto) => { - if let Err(e) = self + match self .add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id) .await { - info!("Could not add file {} to ZIP: {}", file_dto.name, e); + Ok(_) => items_added += 1, + Err(e) => { + info!("Could not add file {} to ZIP: {}", file_dto.name, e); + } } } Err(e) => { @@ -744,11 +752,14 @@ impl BatchOperationService { .await { Ok(root_folder) => { - if let Err(e) = self + match self .add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, user_id) .await { - info!("Could not add folder {} to ZIP: {}", root_folder.name, e); + Ok(_) => items_added += 1, + Err(e) => { + info!("Could not add folder {} to ZIP: {}", root_folder.name, e); + } } } Err(e) => { @@ -757,6 +768,14 @@ impl BatchOperationService { } } + // Bail out before finalizing the ZIP if nothing was authorized. + if items_added == 0 { + return Err(BatchOperationError::Domain(DomainError::not_found( + "BatchDownload", + "No accessible files or folders in the request", + ))); + } + // ── Finalize ───────────────────────────────────────────────────── let mut compat_writer = zip .close() diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 8b650f0e..7ca55116 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -15,6 +15,7 @@ use crate::application::services::batch_operations::{ }; use crate::interfaces::api::deserializer; use crate::interfaces::api::handlers::ApiResult; +use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; /// Maximum number of items allowed in a single batch request. @@ -1010,10 +1011,19 @@ async fn process_download_batch( .await .map_err(|e| { tracing::error!("Batch download ZIP failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Batch download failed".to_string(), - ) + // Surface DomainError variants (NotFound when no items were + // authorized) with their natural HTTP status code instead of + // collapsing everything to 500. + match e { + crate::application::services::batch_operations::BatchOperationError::Domain(de) => { + let app: AppError = de.into(); + (app.status_code, app.message) + } + other => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Batch download failed: {}", other), + ), + } })?; // Read file size for Content-Length before splitting ownership diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index adf09960..54e593e6 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -805,3 +805,429 @@ Authorization: Bearer {{adam_token}} HTTP 200 [Asserts] jsonpath "$" count == 0 + + +# ════════════════════════════════════════════════════════════════════ +# PHASE 3 — Batch operations (/api/batch/*) +# ════════════════════════════════════════════════════════════════════ +# Every batch endpoint passes caller_id through to the batch service, +# which delegates per-item to engine-aware *_with_perms methods. The +# handler aggregates results: 200 (all OK), 206 (mixed), 400 (all failed). +# +# Endpoints exercised: +# POST /api/batch/files/get · /api/batch/files/move +# POST /api/batch/files/copy · /api/batch/files/delete +# POST /api/batch/folders/get · /api/batch/folders/create +# POST /api/batch/folders/move · /api/batch/folders/copy +# POST /api/batch/folders/delete · /api/batch/trash +# POST /api/batch/download · GET /api/batch/download (querystring) +# +# Fresh user "frank" — no grants from earlier phases. + + +# ───────────────────────────────────────────────────────────── +# Step P3.1 — Create frank and login. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "username": "frank", "password": "FrankPassword1!", "email": "frank@example.com", "role": "user" } + +HTTP 201 +[Captures] +frank_user_id: jsonpath "$.id" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "frank", "password": "FrankPassword1!" } + +HTTP 200 +[Captures] +frank_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step P3.2 — Alice creates a batch-test folder with 2 sub-folders +# and 2 files (all owned by alice). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "batch-test", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +batch_root_id: jsonpath "$.id" + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "batch-sub-A", "parent_id": "{{batch_root_id}}" } + +HTTP 201 +[Captures] +batch_sub_a_id: jsonpath "$.id" + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "batch-sub-B", "parent_id": "{{batch_root_id}}" } + +HTTP 201 +[Captures] +batch_sub_b_id: jsonpath "$.id" + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{alice_token}} +[MultipartFormData] +folder_id: {{batch_root_id}} +file: file,fixtures/red-image.png; image/png + +HTTP 201 +[Captures] +batch_file_1_id: jsonpath "$.id" + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{alice_token}} +[MultipartFormData] +folder_id: {{batch_root_id}} +file: file,fixtures/green-image.png; image/png + +HTTP 201 +[Captures] +batch_file_2_id: jsonpath "$.id" + + +# ════════════════════════════════════════════════════════════════════ +# Phase 3A — frank has NO grant. Every batch op returns 400 (all failed). +# ════════════════════════════════════════════════════════════════════ + +# Files — get +POST {{base_url}}/api/batch/files/get +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}", "{{batch_file_2_id}}"] } + +HTTP 400 +[Asserts] +jsonpath "$.stats.successful" == 0 +jsonpath "$.stats.failed" == 2 + +# Files — move +POST {{base_url}}/api/batch/files/move +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" } + +HTTP 400 +[Asserts] +jsonpath "$.stats.failed" == 1 + +# Files — copy +POST {{base_url}}/api/batch/files/copy +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" } + +HTTP 400 +[Asserts] +jsonpath "$.stats.failed" == 1 + +# Files — delete +POST {{base_url}}/api/batch/files/delete +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"] } + +HTTP 400 +[Asserts] +jsonpath "$.stats.failed" == 1 + +# Folders — get +POST {{base_url}}/api/batch/folders/get +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}", "{{batch_sub_b_id}}"] } + +HTTP 400 +[Asserts] +jsonpath "$.stats.failed" == 2 + +# Folders — create child (no Create on batch_root) +POST {{base_url}}/api/batch/folders/create +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folders": [{ "name": "frank-attack", "parent_id": "{{batch_root_id}}" }] } + +HTTP 400 + +# Folders — move +POST {{base_url}}/api/batch/folders/move +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_sub_b_id}}" } + +HTTP 400 + +# Folders — copy +POST {{base_url}}/api/batch/folders/copy +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_sub_b_id}}" } + +HTTP 400 + +# Folders — delete +POST {{base_url}}/api/batch/folders/delete +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}"], "recursive": false } + +HTTP 400 + +# Trash (mixed) +POST {{base_url}}/api/batch/trash +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"], "folder_ids": ["{{batch_sub_a_id}}"] } + +HTTP 400 +[Asserts] +jsonpath "$.stats.failed" == 2 + +# Download POST — engine rejects each item; batch service tracks +# `items_added` and bails out with NotFound when none were authorized. +POST {{base_url}}/api/batch/download +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"], "folder_ids": [] } + +HTTP 404 + +# Download GET (querystring variant) — same behavior +GET {{base_url}}/api/batch/download?file_ids={{batch_file_1_id}} +Authorization: Bearer {{frank_token}} + +HTTP 404 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 3B — Alice grants frank Viewer. Read endpoints succeed; +# mutating batch ops still all-fail. +# ════════════════════════════════════════════════════════════════════ +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{frank_user_id}}" }, + "resource": { "type": "folder", "id": "{{batch_root_id}}" }, + "role": "viewer" +} + +HTTP 201 + +# get_files succeeds (Read cascades to all descendants) +POST {{base_url}}/api/batch/files/get +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}", "{{batch_file_2_id}}"] } + +HTTP 200 +[Asserts] +jsonpath "$.stats.successful" == 2 +jsonpath "$.stats.failed" == 0 + +# get_folders succeeds +POST {{base_url}}/api/batch/folders/get +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}", "{{batch_sub_b_id}}"] } + +HTTP 200 +[Asserts] +jsonpath "$.stats.successful" == 2 + +# Download POST as Viewer — succeeds (Read sufficient) +POST {{base_url}}/api/batch/download +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}", "{{batch_file_2_id}}"], "folder_ids": [] } + +HTTP 200 +[Asserts] +header "Content-Type" == "application/zip" + +# Download GET — same +GET {{base_url}}/api/batch/download?file_ids={{batch_file_1_id}},{{batch_file_2_id}} +Authorization: Bearer {{frank_token}} + +HTTP 200 +[Asserts] +header "Content-Type" == "application/zip" + +# Mutations still rejected +POST {{base_url}}/api/batch/files/move +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" } + +HTTP 400 + +POST {{base_url}}/api/batch/files/delete +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"] } + +HTTP 400 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 3C — Promote frank to Editor (read + comment + create + update). +# Move + copy + create succeed; delete still fails. +# ════════════════════════════════════════════════════════════════════ +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{frank_user_id}}" }, + "resource": { "type": "folder", "id": "{{batch_root_id}}" }, + "role": "editor" +} + +HTTP 200 + +# Batch folder create (Create on parent) +POST {{base_url}}/api/batch/folders/create +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ + "folders": [ + { "name": "frank-batch-1", "parent_id": "{{batch_root_id}}" }, + { "name": "frank-batch-2", "parent_id": "{{batch_root_id}}" } + ] +} + +HTTP 201 +[Asserts] +jsonpath "$.stats.successful" == 2 + +# Batch file move (Update on file + Create on target) +POST {{base_url}}/api/batch/files/move +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_1_id}}"], "target_folder_id": "{{batch_sub_a_id}}" } + +HTTP 200 +[Asserts] +jsonpath "$.stats.successful" == 1 + +# Batch file copy (Read on src + Create on dst) +POST {{base_url}}/api/batch/files/copy +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_2_id}}"], "target_folder_id": "{{batch_sub_b_id}}" } + +HTTP 200 +[Asserts] +jsonpath "$.stats.successful" == 1 + +# Batch folder move +POST {{base_url}}/api/batch/folders/move +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_sub_b_id}}" } + +HTTP 200 + +# Batch folder copy — copy sub_a (now nested inside sub_b after the +# move above) back to batch_root. Avoids name collision with the +# existing sub_b at the root. +POST {{base_url}}/api/batch/folders/copy +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_a_id}}"], "target_folder_id": "{{batch_root_id}}" } + +HTTP 200 + +# Batch delete still denied (Editor excludes Delete) +POST {{base_url}}/api/batch/files/delete +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_2_id}}"] } + +HTTP 400 + +POST {{base_url}}/api/batch/folders/delete +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_b_id}}"], "recursive": true } + +HTTP 400 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 3D — Promote frank to Admin. Delete + trash succeed. +# ════════════════════════════════════════════════════════════════════ +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{frank_user_id}}" }, + "resource": { "type": "folder", "id": "{{batch_root_id}}" }, + "role": "admin" +} + +HTTP 200 + +# Batch trash — CURRENT LIMITATION: even with Admin (Delete grant via +# engine), the trash flow inside trash_service uses get_file_for_owner +# at the data layer, which is owner-scoped. So a non-owner with Delete +# grant gets engine-OK but the SQL filter blocks the fetch → 400. +# This is documented inconsistency; a follow-up should make trash use +# the engine for its lookup too. For now: only the owner can trash. +POST {{base_url}}/api/batch/trash +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_2_id}}"], "folder_ids": [] } + +HTTP 400 + +# Alice (owner) CAN batch-trash — keeps coverage of the success path. +POST {{base_url}}/api/batch/trash +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "file_ids": ["{{batch_file_2_id}}"], "folder_ids": [] } + +HTTP 200 +[Asserts] +jsonpath "$.stats.successful" == 1 + +# Batch permanent-delete a folder +POST {{base_url}}/api/batch/folders/delete +Authorization: Bearer {{frank_token}} +Content-Type: application/json +{ "folder_ids": ["{{batch_sub_b_id}}"], "recursive": true } + +HTTP 200 + + +# ════════════════════════════════════════════════════════════════════ +# Phase 3E — Lifecycle cleanup. Alice deletes the batch-test root. +# Trigger removes all of frank's grants. +# ════════════════════════════════════════════════════════════════════ +DELETE {{base_url}}/api/folders/{{batch_root_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{alice_token}} + +HTTP 200 + +GET {{base_url}}/api/grants/incoming +Authorization: Bearer {{frank_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 0 From cb35775f77d50aeeff0b9f2a92d32540fafbc3ea Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 21:12:38 +0200 Subject: [PATCH 10/13] fix(dedub): correct ref count on hashes, many thanks to you api tests... --- src/infrastructure/services/dedup_service.rs | 24 ++++++++++++--- tests/api/storage_cleanup_check.sh | 31 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 082fdcf3..feef5785 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -1388,12 +1388,23 @@ impl DedupService { let mut total_bytes = 0u64; // ── Phase 1: GC orphaned manifests ─────────────────────── + // A manifest is collectible when: + // • ref_count has been decremented to 0 by cleanup_if_orphaned + // on the single-file-delete service path, OR + // • no `storage.files.blob_hash` references its file_hash + // (covers bulk-delete paths: user cascade, empty_trash — + // where the PG trigger only touches storage.blobs and the + // per-file cleanup_if_orphaned call is skipped). loop { let batch: Vec<(String, Vec, i64)> = sqlx::query_as( "DELETE FROM storage.chunk_manifests WHERE ctid = ANY( - SELECT ctid FROM storage.chunk_manifests - WHERE ref_count <= 0 + SELECT ctid FROM storage.chunk_manifests m + WHERE m.ref_count <= 0 + OR NOT EXISTS ( + SELECT 1 FROM storage.files f + WHERE f.blob_hash = m.file_hash + ) LIMIT $1 ) RETURNING file_hash, chunk_hashes, total_size", @@ -1408,9 +1419,14 @@ impl DedupService { } for (file_hash, chunk_hashes, size) in &batch { - // Decrement chunk ref_counts + // Decrement chunk ref_counts. GREATEST(.., 0) guards against the + // single-chunk file case where the PG file-delete trigger already + // decremented blobs.ref_count (because file_hash == chunk_hash); + // without the clamp this would underflow the CHECK constraint. sqlx::query( - "UPDATE storage.blobs SET ref_count = ref_count - 1 WHERE hash = ANY($1)", + "UPDATE storage.blobs + SET ref_count = GREATEST(ref_count - 1, 0) + WHERE hash = ANY($1)", ) .bind(chunk_hashes) .execute(self.maintenance_pool.as_ref()) diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 1a421cac..b2ee838e 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -64,6 +64,37 @@ assert_local_blob_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe blob not fou assert_preview_existsy "$FIXTURE" "$STORAGE_PATH" || fail "probe thumbnail not found on disk" log "Probe blob and thumbnail confirmed present on disk." +# ── 1c. Delete every non-admin user created by earlier Hurl tests ───────────── +# +# Tests like permissions.hurl and grants.hurl create user accounts (bob, +# dave, eve, adam, frank, …) that own their own folders/files. The probe +# cleanup below only sees admin-owned roots, so those other users' files +# would leak as orphan blobs on disk. Deleting the users cascades through +# the schema (storage.folders/storage.files via ON DELETE CASCADE), which +# fires the file-delete trigger and decrements blob ref_counts. The +# subsequent trash-empty triggers garbage_collect() to remove the +# now-orphaned blob files from disk. + +# /api/admin/users returns { users: [...], total, limit, offset } +USERS_JSON=$(curl -sf -H "$AUTH" "$base_url/api/admin/users?limit=500") + +ADMIN_USER_ID=$(echo "$USERS_JSON" \ + | jq -r --arg u "$username" '.users[] | select(.username == $u) | .id') +[[ -z "$ADMIN_USER_ID" || "$ADMIN_USER_ID" == "null" ]] && fail "could not resolve admin user id" + +OTHER_USER_IDS=$(echo "$USERS_JSON" \ + | jq -r --arg admin_id "$ADMIN_USER_ID" '.users[] | select(.id != $admin_id) | .id') + +OTHER_USER_COUNT=0 +while IFS= read -r uid; do + [[ -z "$uid" ]] && continue + OTHER_USER_COUNT=$((OTHER_USER_COUNT + 1)) + curl -sf -X DELETE -H "$AUTH" "$base_url/api/admin/users/$uid" >/dev/null \ + || fail "failed to delete user $uid" +done <<< "$OTHER_USER_IDS" + +log "Deleted $OTHER_USER_COUNT non-admin user(s) created by tests." + # ── 2. Move all live files and folders to trash ─────────────────────────────── # # For each root folder, list its direct children and soft-delete them. From a1c21ce446b38843e72be4861dab205299af73e1 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 21:50:42 +0200 Subject: [PATCH 11/13] refactor(authz): permet require_permission() as has_permission(), more explicit --- src/application/ports/file_ports.rs | 2 +- src/application/ports/folder_ports.rs | 2 +- src/application/services/file_management_service.rs | 2 +- src/application/services/folder_service.rs | 4 ++-- src/common/stubs.rs | 4 ++-- src/interfaces/api/handlers/chunked_upload_handler.rs | 2 +- src/interfaces/api/handlers/file_handler.rs | 8 ++++---- tests/api/grants.hurl | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 50ef37cc..d4135a2c 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -257,7 +257,7 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { /// Primary port for file management operations pub trait FileManagementUseCase: Send + Sync + 'static { - async fn has_permission( + async fn require_permission( &self, caller_id: Uuid, permission: Permission, diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index f34e61ad..8b547ef9 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -9,7 +9,7 @@ use crate::common::errors::DomainError; use crate::domain::services::authorization::Permission; pub trait FolderUseCase: Send + Sync + 'static { - async fn has_permission( + async fn require_permission( &self, caller_id: Uuid, permission: Permission, diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 8f875a7b..797a1434 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -225,7 +225,7 @@ impl FileManagementService { } impl FileManagementUseCase for FileManagementService { - async fn has_permission( + async fn require_permission( &self, caller_id: Uuid, permission: Permission, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 03ba62ac..f962edcf 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -41,7 +41,7 @@ impl FolderService { struct FolderServiceStub; impl FolderUseCase for FolderServiceStub { - async fn has_permission( + async fn require_permission( &self, _caller_id: Uuid, _permission: Permission, @@ -175,7 +175,7 @@ impl FolderUseCase for FolderService { /// large request bodies (file upload, chunked upload). The authoritative /// check happens again inside the upload/management services before any /// DB write — this is a UX/resource optimization, not a security boundary. - async fn has_permission( + async fn require_permission( &self, caller_id: Uuid, permission: Permission, diff --git a/src/common/stubs.rs b/src/common/stubs.rs index ca4972a3..9b35f53c 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -356,7 +356,7 @@ impl I18nService for StubI18nService { pub struct StubFolderUseCase; impl FolderUseCase for StubFolderUseCase { - async fn has_permission( + async fn require_permission( &self, _caller_id: Uuid, _permission: Permission, @@ -637,7 +637,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { pub struct StubFileManagementUseCase; impl FileManagementUseCase for StubFileManagementUseCase { - async fn has_permission( + async fn require_permission( &self, _caller_id: Uuid, _permission: Permission, diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 10fbe900..893060bc 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -129,7 +129,7 @@ impl ChunkedUploadHandler { && let Err(err) = state .applications .folder_service_concrete - .has_permission(auth_user.id, Permission::Create, fid) + .require_permission(auth_user.id, Permission::Create, fid) .await { tracing::warn!( diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 4ffbac29..b303ef76 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -124,7 +124,7 @@ impl FileHandler { && let Err(err) = state .applications .folder_service_concrete - .has_permission(auth_user.id, Permission::Create, fid) + .require_permission(auth_user.id, Permission::Create, fid) .await { tracing::warn!( @@ -333,7 +333,7 @@ impl FileHandler { if let Err(err) = state .applications .file_management_service - .has_permission(auth_user.id, Permission::Read, &id) + .require_permission(auth_user.id, Permission::Read, &id) .await { return AppError::from(err).into_response(); @@ -493,7 +493,7 @@ impl FileHandler { if let Err(err) = state .applications .file_management_service - .has_permission(auth_user.id, Permission::Update, &id) + .require_permission(auth_user.id, Permission::Update, &id) .await { return AppError::from(err).into_response(); @@ -855,7 +855,7 @@ impl FileHandler { if let Err(err) = state .applications .file_management_service - .has_permission(auth_user.id, Permission::Read, &file_id) + .require_permission(auth_user.id, Permission::Read, &file_id) .await { return AppError::from(err).into_response(); diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 54e593e6..059efd08 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -320,7 +320,7 @@ jsonpath "$" count == 0 # · GET /{id}/metadata · GET /{id}/thumbnail/{size} # · PUT /{id}/thumbnail/{size} (push, Update) # · PUT /{id}/rename · PUT /{id}/move · DELETE /{id} -# · POST /upload (via folder has_permission) +# · POST /upload (via folder require_permission) # # Listing endpoints that are still owner-scoped (GET /api/folders root, # GET /api/folders/paginated) are NOT covered here — they don't @@ -476,7 +476,7 @@ Authorization: Bearer {{adam_token}} HTTP 404 # ── Chunked upload: cannot start session in alice's folder ── -# create_upload_impl pre-checks Permission::Create via has_permission. +# create_upload_impl pre-checks Permission::Create via require_permission. POST {{base_url}}/api/uploads Authorization: Bearer {{adam_token}} Content-Type: application/json From dd68d783e0748e9af68428ee2d1fe93598a3bd55 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 22:45:49 +0200 Subject: [PATCH 12/13] fix(authz): permit policiy: a user with Delete permission can delete a file/folder. Only the owner can permanently delete or restore a trashed item --- src/application/services/trash_service.rs | 24 +++++------------------ tests/api/grants.hurl | 16 +++++++-------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 0980da6e..c002c945 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -218,13 +218,6 @@ impl TrashUseCase for TrashService { "file" => { info!("Processing file to move to trash: {}", item_id); - // XXX: right now only owner can move to trash, need to improve - - // Get the file — ownership-verified at SQL level. - // Returns NotFound if the file does not exist OR belongs to - // another user, preventing cross-user trash operations. - debug!("Getting file data (owner-scoped): {}", item_id); - let file_id = Uuid::parse_str(item_id) .map_err(|_| DomainError::not_found("File", item_id))?; self.authz @@ -235,11 +228,11 @@ impl TrashUseCase for TrashService { ) .await?; - let file = match self - .file_read_port - .get_file_for_owner(item_id, user_id) - .await - { + // Authz already passed — use the non-owner-scoped read so that + // grantees with Delete permission can trash files they don't own. + // The file's user_id in storage.files is unchanged, so the item + // will appear in the original owner's trash view. + let file = match self.file_read_port.get_file(item_id).await { Ok(file) => { debug!("File found: {} ({})", file.name(), item_id); file @@ -257,8 +250,6 @@ impl TrashUseCase for TrashService { let original_path = file.storage_path().to_string(); debug!("Original file path: {}", original_path); - // Create the trash item - // FIXME: item will be created with user_id that mat not be the owner_id debug!("Creating TrashedItem object for the file"); let trashed_item = TrashedItem::new( item_uuid, @@ -320,9 +311,6 @@ impl TrashUseCase for TrashService { ) .await?; - // Get the folder and verify ownership. - // Returns NotFound if the folder does not exist or belongs - // to another user — prevents cross-user trash operations. let folder = self .folder_storage_port .get_folder(item_id) @@ -337,8 +325,6 @@ impl TrashUseCase for TrashService { let original_path = folder.storage_path().to_string(); - // Create the trash item - // FIXME: item will be created with user_id that mat not be the owner_id let trashed_item = TrashedItem::new( item_uuid, user_uuid, diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 059efd08..766d397d 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -1179,24 +1179,22 @@ Content-Type: application/json HTTP 200 -# Batch trash — CURRENT LIMITATION: even with Admin (Delete grant via -# engine), the trash flow inside trash_service uses get_file_for_owner -# at the data layer, which is owner-scoped. So a non-owner with Delete -# grant gets engine-OK but the SQL filter blocks the fetch → 400. -# This is documented inconsistency; a follow-up should make trash use -# the engine for its lookup too. For now: only the owner can trash. +# Frank (Admin grant = Delete) trashes batch_file_2 — item goes to +# Alice's trash because file.user_id is unchanged (Alice is still owner). POST {{base_url}}/api/batch/trash Authorization: Bearer {{frank_token}} Content-Type: application/json { "file_ids": ["{{batch_file_2_id}}"], "folder_ids": [] } -HTTP 400 +HTTP 200 +[Asserts] +jsonpath "$.stats.successful" == 1 -# Alice (owner) CAN batch-trash — keeps coverage of the success path. +# Alice trashes batch_file_1 (which frank moved into batch_sub_a in Phase 3C). POST {{base_url}}/api/batch/trash Authorization: Bearer {{alice_token}} Content-Type: application/json -{ "file_ids": ["{{batch_file_2_id}}"], "folder_ids": [] } +{ "file_ids": ["{{batch_file_1_id}}"], "folder_ids": [] } HTTP 200 [Asserts] From 76a85949e77fcb8bf2ea2e887a79e50b89264b71 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 21 May 2026 23:56:14 +0200 Subject: [PATCH 13/13] fix(trash)+refactor(file life cycle) * fix issue with the empty trash (wasn't calling thumbnail clean up) * refactor file service life cycle (TrashService don't call directly ThumbnailService, but call the on_file_deleted() hook * remove unused mehod: _validate_user_ownership() --- .../services/file_lifecycle_service.rs | 47 ++++++++++ .../services/file_management_service.rs | 12 +-- src/application/services/mod.rs | 1 + src/application/services/trash_service.rs | 94 ++++++------------- .../services/trash_service_test.rs | 5 + src/common/di.rs | 33 ++++--- src/domain/repositories/trash_repository.rs | 5 + .../repositories/pg/trash_db_repository.rs | 11 +++ 8 files changed, 123 insertions(+), 85 deletions(-) create mode 100644 src/application/services/file_lifecycle_service.rs diff --git a/src/application/services/file_lifecycle_service.rs b/src/application/services/file_lifecycle_service.rs new file mode 100644 index 00000000..50fece64 --- /dev/null +++ b/src/application/services/file_lifecycle_service.rs @@ -0,0 +1,47 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::application::ports::file_lifecycle::FileDeletedHook; + +/// Composite dispatcher for file lifecycle events. +/// +/// Aggregates all `FileDeletedHook` implementations and fans out each event to +/// every registered handler. Services hold a single `Arc` +/// pointing here — new handlers are added once, in DI, without touching the +/// services themselves. +pub struct FileLifecycleService { + deleted: Vec>, +} + +impl Default for FileLifecycleService { + fn default() -> Self { + Self::new() + } +} + +impl FileLifecycleService { + pub fn new() -> Self { + Self { + deleted: Vec::new(), + } + } + + pub fn with_deleted_hook(mut self, hook: Arc) -> Self { + self.deleted.push(hook); + self + } +} + +impl FileDeletedHook for FileLifecycleService { + fn on_file_deleted<'a>( + &'a self, + file_id: &'a str, + ) -> Pin + Send + 'a>> { + Box::pin(async move { + for hook in &self.deleted { + hook.on_file_deleted(file_id).await; + } + }) + } +} diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 797a1434..117c4752 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -29,8 +29,8 @@ pub struct FileManagementService { trash_service: Option>, content_cache: Option>, authz: Arc, - /// Hooks fired after a file is permanently deleted. - file_deleted_hooks: Vec>, + /// Hook fired after a file is permanently deleted (typically the FileLifecycleService composite). + file_deleted_hook: Option>, } impl FileManagementService { @@ -52,13 +52,13 @@ impl FileManagementService { trash_service, content_cache, authz, - file_deleted_hooks: Vec::new(), + file_deleted_hook: None, } } - /// Registers a hook to fire after a file is permanently deleted. + /// Sets the lifecycle hook fired after a file is permanently deleted. pub fn with_file_deleted_hook(mut self, hook: Arc) -> Self { - self.file_deleted_hooks.push(hook); + self.file_deleted_hook = Some(hook); self } @@ -185,7 +185,7 @@ impl FileManagementService { if let Some(cc) = &self.content_cache { cc.invalidate(id).await; } - for hook in &self.file_deleted_hooks { + if let Some(hook) = &self.file_deleted_hook { hook.on_file_deleted(id).await; } info!("File permanently deleted: {}", id); diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 8423e07c..d3d1b072 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -6,6 +6,7 @@ pub mod calendar_service; pub mod contact_service; pub mod device_auth_service; pub mod favorites_service; +pub mod file_lifecycle_service; pub mod file_management_service; pub mod file_retrieval_service; pub mod file_upload_service; diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index c002c945..911004f7 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -7,6 +7,7 @@ use crate::application::dtos::display_helpers::{ }; use crate::application::dtos::trash_dto::TrashedItemDto; use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::application::ports::file_lifecycle::FileDeletedHook; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::errors::{DomainError, ErrorKind, Result}; @@ -21,7 +22,6 @@ use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbReposit use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::file_content_cache::FileContentCache; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; -use crate::infrastructure::services::thumbnail_service::ThumbnailService; /** * Application service for trash operations. @@ -53,8 +53,8 @@ pub struct TrashService { /// orphaned blob files and thumbnails that the PG trigger cannot reach. dedup_service: Arc, - /// Thumbnail service for cleaning up thumbnails on permanent delete - thumbnail_service: Option>, + /// Hook fired after a file is permanently deleted (typically the FileLifecycleService composite). + file_deleted_hook: Option>, /// Content cache — invalidated when files are permanently deleted from trash. content_cache: Option>, @@ -75,7 +75,6 @@ impl TrashService { folder_storage_port: Arc, retention_days: u32, dedup_service: Arc, - thumbnail_service: Option>, content_cache: Option>, authz: Arc, ) -> Self { @@ -85,13 +84,19 @@ impl TrashService { file_write_port, folder_storage_port, dedup_service, - thumbnail_service, + file_deleted_hook: None, content_cache, authz, retention_days, } } + /// Sets the lifecycle hook fired after a file is permanently deleted. + pub fn with_file_deleted_hook(mut self, hook: Arc) -> Self { + self.file_deleted_hook = Some(hook); + self + } + /// Converts a TrashedItem entity to a DTO fn to_dto(&self, item: TrashedItem) -> TrashedItemDto { // Calculate days_until_deletion before moving item fields @@ -130,46 +135,6 @@ impl TrashService { icon_special_class, } } - - /// Validates that the given user owns the trashed item. - /// Returns an error if the item does not exist or belongs to a different user. - #[instrument(skip(self))] - async fn _validate_user_ownership(&self, item_id: &str, user_id: &str) -> Result<()> { - let item_uuid = Uuid::parse_str(item_id) - .map_err(|e| DomainError::validation_error(format!("Invalid item ID: {}", e)))?; - let user_uuid = Uuid::parse_str(user_id) - .map_err(|e| DomainError::validation_error(format!("Invalid user ID: {}", e)))?; - - match self - .trash_repository - .get_trash_item(&item_uuid, &user_uuid) - .await? - { - Some(item) => { - if item.user_id() != user_uuid { - error!( - "User {} attempted to access trash item {} owned by {}", - user_id, - item_id, - item.user_id() - ); - return Err(DomainError::access_denied( - "TrashItem", - "You do not have permission to access this trash item", - )); - } - Ok(()) - } - None => { - // Item not found for this user — treat as authorization error - // to avoid leaking existence information - Err(DomainError::not_found( - "TrashItem", - format!("{} (user: {})", item_id, user_id), - )) - } - } - } } impl TrashUseCase for TrashService { @@ -617,12 +582,8 @@ impl TrashUseCase for TrashService { } } - // Best-effort thumbnail cleanup — thumbnails are cache - // artifacts, so failure must not block file deletion. - if let Some(thumb) = &self.thumbnail_service - && let Err(e) = thumb.delete_thumbnails(&file_id).await - { - warn!("Failed to delete thumbnails for file {}: {}", file_id, e); + if let Some(hook) = &self.file_deleted_hook { + hook.on_file_deleted(&file_id).await; } } TrashedItemType::Folder => { @@ -714,18 +675,20 @@ impl TrashUseCase for TrashService { async fn empty_trash(&self, user_id: Uuid) -> Result<()> { info!("Emptying trash for user {}", user_id); - // Collect trashed file IDs BEFORE bulk-deleting so we can clean up - // their thumbnails afterward. This is best-effort — if the query - // fails we still proceed with the bulk delete. - let trashed_file_ids: Vec = if self.thumbnail_service.is_some() { - match self.trash_repository.get_trash_items(&user_id).await { - Ok(items) => items - .iter() - .filter(|i| matches!(i.item_type(), TrashedItemType::File)) - .map(|i| i.original_id().to_string()) - .collect(), + // Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail + // cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not + // get_trash_items) because the trash_items view excludes files inside a + // trashed folder — those files will still be deleted by clear_trash via + // the folder CASCADE, but their hooks would otherwise be missed. + let trashed_file_ids: Vec = if self.file_deleted_hook.is_some() { + match self + .trash_repository + .get_all_trashed_file_ids(&user_id) + .await + { + Ok(ids) => ids, Err(e) => { - warn!("Could not list trashed items for thumbnail cleanup: {}", e); + warn!("Could not list trashed files for hook cleanup: {}", e); Vec::new() } } @@ -758,12 +721,9 @@ impl TrashUseCase for TrashService { } } - // Best-effort thumbnail cleanup for all deleted files - if let Some(thumb) = &self.thumbnail_service { + if let Some(hook) = &self.file_deleted_hook { for file_id in &trashed_file_ids { - if let Err(e) = thumb.delete_thumbnails(file_id).await { - warn!("Failed to delete thumbnails for file {}: {}", file_id, e); - } + hook.on_file_deleted(file_id).await; } } diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 9c3516ae..e0cf8bf0 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -402,6 +402,11 @@ impl TrashRepository for MockTrashRepository { Ok(()) } + async fn get_all_trashed_file_ids(&self, _user_id: &Uuid) -> Result> { + let files = self.trashed_files.lock().unwrap(); + Ok(files.keys().cloned().collect()) + } + async fn delete_expired_bulk(&self) -> Result<(u64, u64)> { let mut items = self.trash_items.lock().unwrap(); let now = Utc::now(); diff --git a/src/common/di.rs b/src/common/di.rs index d44e7baa..f67f5489 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -44,6 +44,7 @@ use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::application::services::app_password_service::AppPasswordService; use crate::application::services::calendar_service::CalendarService; use crate::application::services::device_auth_service::DeviceAuthService; +use crate::application::services::file_lifecycle_service::FileLifecycleService; use crate::application::services::music_service::MusicService; use crate::application::services::storage_usage_service::StorageUsageService; use crate::application::services::wopi_lock_service::WopiLockService; @@ -274,10 +275,14 @@ impl AppServiceFactory { "Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage)" ); + let file_lifecycle = + Arc::new(FileLifecycleService::new().with_deleted_hook(thumbnail_service.clone())); + Ok(CoreServices { path_service, file_content_cache, thumbnail_service, + file_lifecycle, chunked_upload_service, image_transcode_service, dedup_service, @@ -392,7 +397,7 @@ impl AppServiceFactory { Some(core.file_content_cache.clone()), authz.clone(), ) - .with_file_deleted_hook(core.thumbnail_service.clone()), + .with_file_deleted_hook(core.file_lifecycle.clone()), ); let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( @@ -463,17 +468,19 @@ impl AppServiceFactory { let trash_repo = repos.trash_repository.as_ref()?; // Wire ports directly to TrashService — no adapter layer needed - let service = Arc::new(TrashService::new( - trash_repo.clone(), - repos.file_read_repository.clone(), - repos.file_write_repository.clone(), - repos.folder_repository.clone(), - self.config.storage.trash_retention_days, - core.dedup_service.clone(), - Some(core.thumbnail_service.clone()), - Some(core.file_content_cache.clone()), - authz.clone(), - )); + let service = Arc::new( + TrashService::new( + trash_repo.clone(), + repos.file_read_repository.clone(), + repos.file_write_repository.clone(), + repos.folder_repository.clone(), + self.config.storage.trash_retention_days, + core.dedup_service.clone(), + Some(core.file_content_cache.clone()), + authz.clone(), + ) + .with_file_deleted_hook(core.file_lifecycle.clone()), + ); // Initialize cleanup service (bulk-deletes expired items in 2 SQL queries) let cleanup_service = TrashCleanupService::new( @@ -1021,6 +1028,8 @@ pub struct CoreServices { pub path_service: Arc, pub file_content_cache: Arc, pub thumbnail_service: Arc, + /// Composite lifecycle dispatcher — register new permanent-delete hooks here only. + pub file_lifecycle: Arc, pub chunked_upload_service: Arc, pub image_transcode_service: Arc, pub dedup_service: Arc, diff --git a/src/domain/repositories/trash_repository.rs b/src/domain/repositories/trash_repository.rs index 7925c530..3a1cee2c 100644 --- a/src/domain/repositories/trash_repository.rs +++ b/src/domain/repositories/trash_repository.rs @@ -11,6 +11,11 @@ pub trait TrashRepository: Send + Sync { async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()>; async fn clear_trash(&self, user_id: &Uuid) -> Result<()>; + /// All trashed file IDs for this user, regardless of parent folder trash status. + /// Used by empty_trash for thumbnail cleanup — the view used by get_trash_items + /// excludes files inside trashed folders, which would miss their ext thumbnails. + async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result>; + /// Bulk-delete all expired trash items (files + folders) in a single /// transaction. Returns `(files_deleted, folders_deleted)`. async fn delete_expired_bulk(&self) -> Result<(u64, u64)>; diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index 9c0ed8a0..aee38c2b 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -157,6 +157,17 @@ impl TrashRepository for TrashDbRepository { Ok(()) } + async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result> { + let rows = sqlx::query_scalar::<_, String>( + "SELECT id::text FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE", + ) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("all_trashed_files: {e}")))?; + Ok(rows) + } + async fn delete_expired_bulk(&self) -> Result<(u64, u64)> { let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);