Merge branch 'main' into claude/performance-optimization-round-6

Resolves the one conflict in file_blob_read_repository.rs's
suggest_files_by_name: main added the CALLER_CAN_READ_DRIVE authz scope
(caller_id param + drive-membership filter, AuthZ audit finding #1 — the
suggest query previously leaked names/paths across tenants), round 6
switched the same query's id/folder_id columns to binary UUID decode.
Kept both: main's authz structure (format! + CALLER_CAN_READ_DRIVE +
caller_id bind) with round 6's binary decode (fi.id / fi.folder_id, no
::text) so the query matches the FileRow = (Uuid, …) tuple. The
deliberately-text sites (min(fm.file_id::text), folder path lookup)
stay text. Verified: build + clippy -D warnings clean, 524 unit +
554 integration tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
This commit is contained in:
Claude
2026-07-18 10:07:42 +00:00
20 changed files with 523 additions and 293 deletions
+105 -35
View File
@@ -16,6 +16,28 @@ use crate::domain::services::authorization::{
ResourceKind, Role, Subject,
};
/// Discriminates the two denial shapes surfaced by
/// [`AuthorizationEngine::require_visible`] in the `authz.denied` audit line.
/// Log-aggregation consumers key off the string form via `as_str`; keep the
/// values stable — a new denial shape means a new variant, never a renamed
/// existing one.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum AuthzDenialVisibility {
/// Caller has `Read` on the resource — 403 Forbidden.
Visible,
/// Caller has no `Read` — 404 anti-enum.
Hidden,
}
impl AuthzDenialVisibility {
pub fn as_str(self) -> &'static str {
match self {
Self::Visible => "visible",
Self::Hidden => "hidden",
}
}
}
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Returns true if `subject` has `permission` on `resource`, considering
/// owner short-circuit AND cascading from folder ancestors.
@@ -53,9 +75,28 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
Ok(allowed)
}
/// 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).
/// Graduated-denial wrapper around `check`. Semantics:
///
/// - `permission` granted → `Ok(())`
/// - `permission` denied, `Read` also denied → `DomainError::not_found`
/// (404, anti-enumeration — same shape as "doesn't exist" so a probing
/// caller can't distinguish "wrong id" from "no access")
/// - `permission` denied, `Read` granted → `DomainError::access_denied`
/// (403 — the caller can already see the resource, so hiding existence
/// leaks nothing new; a clear 403 beats a confusing 404 for UX and for
/// API-first clients like rclone)
///
/// Special case: when `permission == Read`, the visibility gate collapses
/// onto itself — a `Read` denial IS a "hidden" outcome by definition, so
/// the method short-circuits to the strict anti-enum 404 without a second
/// DB round-trip. That's why there's only one method: strict Read-denial
/// and graduated write-denial fall out of the same signature.
///
/// Do NOT use this in search / enumeration paths where existence itself is
/// the attack vector — those must filter at the SQL/index layer, never
/// touch this method with per-row ids. Cross-tenant probes on ids the
/// caller has no prior read handle for degrade to the 404 shape naturally
/// (Read denied → `Hidden`).
async fn require(
&self,
subject: Subject,
@@ -80,39 +121,68 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
permission,
resource
);
Ok(())
return Ok(());
}
// Visibility probe. Short-circuit: when the target permission IS
// `Read` and the check above returned false, we already know Read is
// denied — visibility is `Hidden` by definition, no second DB hop.
// Otherwise probe Read; a DB-hop failure here degrades to `Hidden` so
// the caller sees the strict anti-enum shape (safe default).
let visibility = if permission == Permission::Read {
AuthzDenialVisibility::Hidden
} else if self
.check(subject, Permission::Read, resource)
.await
.unwrap_or(false)
{
AuthzDenialVisibility::Visible
} else {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
Resource::Calendar(id) => ("Calendar", id),
Resource::AddressBook(id) => ("AddressBook", id),
Resource::Playlist(id) => ("Playlist", id),
};
// Audit-worthy: denials are the interesting signal. Routed
// through the `audit` tracing target so log aggregators can
// surface them separately from operational debug traffic.
// Span context (request_id, client_ip, user_id) is attached
// automatically by the request-scope span set in
// `interfaces/middleware/trace_span.rs`, so this log line
// doesn't need to duplicate those fields — they appear in
// the structured output of every log written inside the
// request span.
tracing::info!(
target: "audit",
event = "authz.denied",
subject_type = subject.type_str(),
subject_id = %subject.id(),
permission = permission.as_str(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
"👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
subject,
permission,
resource
);
Err(DomainError::not_found(kind, id.to_string()))
AuthzDenialVisibility::Hidden
};
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
Resource::Calendar(id) => ("Calendar", id),
Resource::AddressBook(id) => ("AddressBook", id),
Resource::Playlist(id) => ("Playlist", id),
};
// Audit-worthy: denials are the interesting signal. Routed through
// the `audit` tracing target so log aggregators can surface them
// separately from operational debug traffic. Span context
// (request_id, client_ip, user_id) comes from the request-scope
// span set in `interfaces/middleware/trace_span.rs`, so this line
// doesn't need to duplicate those fields.
//
// The `visibility` field discriminates the two denial shapes for
// operators grepping exists-but-denied vs fully-hidden. `visible`
// denials are the ones surfaced to the caller as 403 (and safe to
// detail in the UI); `hidden` denials are the 404 anti-enum path.
tracing::info!(
target: "audit",
event = "authz.denied",
visibility = visibility.as_str(),
subject_type = subject.type_str(),
subject_id = %subject.id(),
permission = permission.as_str(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
"👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}' (visibility={})",
subject,
permission,
resource,
visibility.as_str()
);
match visibility {
AuthzDenialVisibility::Visible => Err(DomainError::access_denied(
kind,
format!("Missing '{}' permission on {} {}", permission, kind, id),
)),
AuthzDenialVisibility::Hidden => Err(DomainError::not_found(kind, id.to_string())),
}
}
+4
View File
@@ -27,11 +27,15 @@ pub trait SearchUseCase: Send + Sync + 'static {
) -> Result<Arc<SearchResultsDto>, DomainError>;
/// Returns quick suggestions for autocomplete (lightweight, fast).
/// `caller_id` scopes results to drives the caller can Read — without
/// it the endpoint leaks names + paths across every tenant on the
/// instance (AuthZ audit finding #1, 2026-07-12).
async fn suggest(
&self,
query: &str,
folder_id: Option<&str>,
limit: usize,
caller_id: Uuid,
) -> Result<SearchSuggestionsDto, DomainError>;
/// Clears the search results cache.
+10 -2
View File
@@ -205,13 +205,21 @@ pub trait FileReadPort: Send + Sync + 'static {
/// Results are ordered by relevance (exact > starts-with > contains) so the
/// caller can use them directly for autocomplete suggestions.
///
/// The default implementation falls back to `list_files` + in-memory filter
/// so that stubs and mocks compile without changes.
/// `caller_id` scopes results to files whose owning drive the caller can
/// Read (direct or group-mediated `role_grants`). Without it the endpoint
/// leaks names + paths across every tenant on the instance — closed as
/// AuthZ audit finding #1 (2026-07-12).
///
/// The default implementation falls back to `list_files` + in-memory
/// filter so that stubs and mocks compile without changes. Stub-mode
/// callers already operate against a single tenant's data, so ignoring
/// `caller_id` here is safe; the PG impl enforces the real scope.
async fn suggest_files_by_name(
&self,
folder_id: Option<&str>,
query: &str,
limit: usize,
_caller_id: Uuid,
) -> Result<Vec<File>, DomainError> {
let all = self.list_files(folder_id).await?;
let q = query.to_lowercase();
+20 -5
View File
@@ -497,20 +497,28 @@ impl SearchService {
/// Quick suggestions search — returns up to `limit` name suggestions
/// matching the query. Pushes filtering, relevance sort and LIMIT to SQL
/// so only a handful of rows cross the DB→app boundary.
pub async fn suggest(
///
/// `caller_id` scopes the underlying repo queries to drives the caller
/// can Read. Without it (the pre-fix shape) any authenticated user —
/// including external magic-link recipients — could autocomplete both
/// names and full paths across every tenant on the instance (AuthZ
/// audit finding #1, 2026-07-12). Named `_with_perms` per the
/// AGENTS.md AuthZ convention.
pub async fn suggest_with_perms(
&self,
query: &str,
folder_id: Option<&str>,
limit: usize,
caller_id: Uuid,
) -> Result<SearchSuggestionsDto> {
let start = Instant::now();
// Ask SQL for at most `limit` best-matching files and folders
let (files, folders) = tokio::join!(
self.file_repository
.suggest_files_by_name(folder_id, query, limit),
.suggest_files_by_name(folder_id, query, limit, caller_id),
self.folder_repository
.suggest_folders_by_name(folder_id, query, limit),
.suggest_folders_by_name(folder_id, query, limit, caller_id),
);
let files = files?;
let folders = folders?;
@@ -802,14 +810,20 @@ impl SearchUseCase for SearchService {
})
}
/// Returns quick suggestions for autocomplete.
/// Returns quick suggestions for autocomplete. Delegates to the
/// inherent `suggest_with_perms` — the trait method is preserved as
/// the polymorphic entry point (e.g. for `StubSearchUseCase` in
/// tests); production callers can equivalently call the inherent
/// method directly.
async fn suggest(
&self,
query: &str,
folder_id: Option<&str>,
limit: usize,
caller_id: Uuid,
) -> Result<SearchSuggestionsDto> {
self.suggest(query, folder_id, limit).await
self.suggest_with_perms(query, folder_id, limit, caller_id)
.await
}
/// Clears the search results cache.
@@ -841,6 +855,7 @@ impl SearchService {
_query: &str,
_folder_id: Option<&str>,
_limit: usize,
_caller_id: Uuid,
) -> Result<SearchSuggestionsDto> {
Ok(SearchSuggestionsDto {
suggestions: Vec::new(),
+1
View File
@@ -725,6 +725,7 @@ impl SearchUseCase for StubSearchUseCase {
_query: &str,
_folder_id: Option<&str>,
_limit: usize,
_caller_id: Uuid,
) -> Result<SearchSuggestionsDto, DomainError> {
Ok(SearchSuggestionsDto {
suggestions: Vec::new(),
+9 -1
View File
@@ -269,13 +269,21 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Results are ordered by relevance (exact > starts-with > contains) for
/// autocomplete suggestions.
///
/// `caller_id` scopes results to folders whose owning drive the caller
/// can Read (direct or group-mediated `role_grants`). Without it the
/// endpoint leaked names + paths across every tenant on the instance —
/// closed as AuthZ audit finding #1 (2026-07-12).
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
/// filter so that stubs and mocks compile without changes. Stub-mode
/// callers already operate against a single tenant's data, so ignoring
/// `caller_id` here is safe; the PG impl enforces the real scope.
async fn suggest_folders_by_name(
&self,
parent_id: Option<&str>,
query: &str,
limit: usize,
_caller_id: uuid::Uuid,
) -> Result<Vec<Folder>, DomainError> {
let all = self.list_folders(parent_id).await?;
let q = query.to_lowercase();
@@ -1413,12 +1413,20 @@ impl FileReadPort for FileBlobReadRepository {
folder_id: Option<&str>,
query: &str,
limit: usize,
caller_id: Uuid,
) -> Result<Vec<File>, DomainError> {
// Scope by drive membership: `CALLER_CAN_READ_DRIVE` (`$1` =
// caller_id) restricts the result set to files whose owning drive
// the caller has any active `role_grants` on — direct or via a
// transitive group cascade. Pre-fix, the query only filtered on
// `NOT is_trashed AND name ILIKE $pattern`, exposing names + paths
// across every tenant on the instance (AuthZ audit finding #1,
// 2026-07-12).
let pattern = super::like_escape(query);
let limit_i64 = limit as i64;
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
sqlx::query_as(&format!(
r#"
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
@@ -1429,7 +1437,40 @@ impl FileReadPort for FileBlobReadRepository {
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid
WHERE {CALLER_CAN_READ_DRIVE}
AND fi.folder_id = $2::uuid
AND NOT fi.is_trashed
AND fi.name ILIKE $3
ORDER BY CASE
WHEN fi.name ILIKE $4 THEN 0
WHEN fi.name ILIKE $4 || '%' THEN 1
ELSE 2
END,
fi.name
LIMIT $5
"#
))
.bind(caller_id)
.bind(fid)
.bind(&pattern)
.bind(query)
.bind(limit_i64)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(&format!(
r#"
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE {CALLER_CAN_READ_DRIVE}
AND fi.folder_id IS NULL
AND NOT fi.is_trashed
AND fi.name ILIKE $2
ORDER BY CASE
@@ -1439,38 +1480,9 @@ impl FileReadPort for FileBlobReadRepository {
END,
fi.name
LIMIT $4
"#,
)
.bind(fid)
.bind(&pattern)
.bind(query)
.bind(limit_i64)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT fi.id, fi.name, fi.folder_id, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL
AND NOT fi.is_trashed
AND fi.name ILIKE $1
ORDER BY CASE
WHEN fi.name ILIKE $2 THEN 0
WHEN fi.name ILIKE $2 || '%' THEN 1
ELSE 2
END,
fi.name
LIMIT $3
"#,
)
"#
))
.bind(caller_id)
.bind(&pattern)
.bind(query)
.bind(limit_i64)
@@ -1232,31 +1232,39 @@ impl FolderRepository for FolderDbRepository {
parent_id: Option<&str>,
query: &str,
limit: usize,
caller_id: uuid::Uuid,
) -> Result<Vec<Folder>, DomainError> {
// Same drive-scope filter as `suggest_files_by_name` — closed as
// AuthZ audit finding #1 (2026-07-12). `CALLER_CAN_READ_DRIVE`
// aliases `storage.folders` as `fo`; the pre-fix query aliased it
// as an unqualified `storage.folders`, so this rewrite adds the
// `fo` alias in every branch.
let pattern = super::like_escape(query);
let limit_i64 = limit as i64;
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
sqlx::query_as(&format!(
r#"
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id = $1::uuid
AND NOT is_trashed
AND name ILIKE $2
SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id,
EXTRACT(EPOCH FROM fo.created_at)::bigint,
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
fo.created_by, fo.updated_by
FROM storage.folders fo
WHERE {CALLER_CAN_READ_DRIVE}
AND fo.parent_id = $2::uuid
AND NOT fo.is_trashed
AND fo.name ILIKE $3
ORDER BY CASE
WHEN name ILIKE $3 THEN 0
WHEN name ILIKE $3 || '%' THEN 1
WHEN fo.name ILIKE $4 THEN 0
WHEN fo.name ILIKE $4 || '%' THEN 1
ELSE 2
END,
name
LIMIT $4
"#,
)
fo.name
LIMIT $5
"#
))
.bind(caller_id)
.bind(pid)
.bind(&pattern)
.bind(query)
@@ -1264,26 +1272,28 @@ impl FolderRepository for FolderDbRepository {
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(
sqlx::query_as(&format!(
r#"
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id IS NULL
AND NOT is_trashed
AND name ILIKE $1
SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, fo.drive_id,
EXTRACT(EPOCH FROM fo.created_at)::bigint,
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
fo.created_by, fo.updated_by
FROM storage.folders fo
WHERE {CALLER_CAN_READ_DRIVE}
AND fo.parent_id IS NULL
AND NOT fo.is_trashed
AND fo.name ILIKE $2
ORDER BY CASE
WHEN name ILIKE $2 THEN 0
WHEN name ILIKE $2 || '%' THEN 1
WHEN fo.name ILIKE $3 THEN 0
WHEN fo.name ILIKE $3 || '%' THEN 1
ELSE 2
END,
name
LIMIT $3
"#,
)
fo.name
LIMIT $4
"#
))
.bind(caller_id)
.bind(&pattern)
.bind(query)
.bind(limit_i64)
@@ -140,6 +140,7 @@ impl SearchHandler {
/// Autocomplete suggestions for search.
pub(super) async fn suggest_files_impl(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(params): Query<SuggestParams>,
) -> impl IntoResponse {
info!("API: Search suggestions for {:?}", params.query);
@@ -159,7 +160,12 @@ impl SearchHandler {
let limit = params.limit.unwrap_or(10).min(20);
match search_service
.suggest(&params.query, params.folder_id.as_deref(), limit)
.suggest_with_perms(
&params.query,
params.folder_id.as_deref(),
limit,
auth_user.id,
)
.await
{
Ok(suggestions) => {
@@ -354,9 +360,10 @@ pub async fn search_files_post(
)]
pub async fn suggest_files(
state: State<Arc<AppState>>,
auth_user: AuthUser,
query: Query<SuggestParams>,
) -> impl IntoResponse {
SearchHandler::suggest_files_impl(state, query).await
SearchHandler::suggest_files_impl(state, auth_user, query).await
}
#[utoipa::path(
+34 -36
View File
@@ -2225,18 +2225,27 @@ async fn handle_delete(
// optimized resolver and the read repositories disagree on path
// shape for some files; see `resolve_or_legacy` docs.
let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere
// AuthZ audit #2 (2026-07-12): route service errors through
// `AppError::from` so authz denials from `_with_perms` surface as
// 404 (the anti-enum shape). The prior `map_err(|e| internal_error…)`
// collapsed every error — including the `NotFound` that
// `authz.require` returns on denial — into HTTP 500, giving a
// reliable "exists-but-denied" vs "missing" oracle to a probing
// caller. Also preserves `QuotaExceeded → 507`,
// `AlreadyExists → 409`, `InvalidInput → 400` shapes surfacing
// through the standard error mapping.
match resolve_or_legacy(&state, &path, drive_id).await {
Some(ResolvedResource::Folder(folder)) => {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
.map_err(AppError::from)?;
}
Some(ResolvedResource::File(file)) => {
file_management_service
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
.map_err(AppError::from)?;
}
None => return Err(AppError::not_found(format!("Resource not found: {}", path))),
}
@@ -2385,28 +2394,23 @@ async fn handle_move(
// RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the
// destination before moving. Without this the rename/move fails
// on a unique-index conflict (same name in same parent).
// AuthZ audit #2 (2026-07-12): `_with_perms` returns `DomainError`;
// route through `AppError::from` so authz denials surface as 404 (the
// anti-enum shape) instead of a `map_err → internal_error` 500 that
// gives a probing caller an "exists-but-denied" oracle. Also preserves
// `QuotaExceeded → 507`, `AlreadyExists → 409`, `InvalidInput → 400`.
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
Some(ResolvedResource::Folder(f)) => {
folder_service
.delete_folder_with_perms(&f.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to delete existing destination: {}",
e
))
})?;
.map_err(AppError::from)?;
}
Some(ResolvedResource::File(f)) => {
file_management_service
.delete_file_with_perms(&f.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to delete existing destination: {}",
e
))
})?;
.map_err(AppError::from)?;
}
None => {}
}
@@ -2684,28 +2688,23 @@ async fn handle_copy(
// RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a
// DELETE on the destination before the copy. Without this the copy
// service returns a unique-index conflict (500).
// AuthZ audit #2 (2026-07-12): `_with_perms` returns `DomainError`;
// route through `AppError::from` so authz denials surface as 404 (the
// anti-enum shape) instead of a `map_err → internal_error` 500 that
// gives a probing caller an "exists-but-denied" oracle. Also preserves
// `QuotaExceeded → 507`, `AlreadyExists → 409`, `InvalidInput → 400`.
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
Some(ResolvedResource::Folder(f)) => {
folder_service
.delete_folder_with_perms(&f.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to delete existing destination: {}",
e
))
})?;
.map_err(AppError::from)?;
}
Some(ResolvedResource::File(f)) => {
file_management_service
.delete_file_with_perms(&f.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to delete existing destination: {}",
e
))
})?;
.map_err(AppError::from)?;
}
None => {}
}
@@ -2759,6 +2758,12 @@ async fn handle_copy(
}
};
// AuthZ audit #2 (2026-07-12): route service errors through
// `AppError::from` so authz denials from `_with_perms` surface as 404
// (the anti-enum shape) instead of a `map_err → internal_error` 500
// that gives a probing caller an "exists-but-denied" oracle. Also
// preserves `QuotaExceeded → 507`, `AlreadyExists → 409`,
// `InvalidInput → 400` shapes.
match resolved {
ResolvedResource::Folder(folder) => {
let recursive = depth != "0";
@@ -2771,9 +2776,7 @@ async fn handle_copy(
Some(dest_name.to_string()),
)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to copy folder tree: {}", e))
})?;
.map_err(AppError::from)?;
} else {
let create_dto = crate::application::dtos::folder_dto::CreateFolderDto {
name: dest_name.to_string(),
@@ -2782,12 +2785,7 @@ async fn handle_copy(
folder_service
.create_folder_with_perms(create_dto, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to create destination folder: {}",
e
))
})?;
.map_err(AppError::from)?;
}
}
ResolvedResource::File(file) => {
@@ -2795,7 +2793,7 @@ async fn handle_copy(
file_management_service
.copy_file_with_perms(&file.id, user.id, target_parent_id, copy_name)
.await
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
.map_err(AppError::from)?;
}
}
+42 -32
View File
@@ -932,6 +932,11 @@ async fn handle_put(
// Single streaming path — handles both update and create internally,
// swapping the file row onto the already-ingested blob.
// AuthZ audit #6 (2026-07-12): route `_with_perms` errors through
// `AppError::from` so authz denials surface as 404 (the anti-enum
// shape) instead of a `map_err → internal_error` 500 that gives a
// probing caller an "exists-but-denied" oracle. Also preserves
// `QuotaExceeded → 507`, `AlreadyExists → 409`, `InvalidInput → 400`.
let stored = upload_service
.update_file_streaming_with_perms(
&internal_path,
@@ -942,7 +947,7 @@ async fn handle_put(
session.user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
.map_err(AppError::from)?;
let status = if existed {
StatusCode::NO_CONTENT
@@ -1031,10 +1036,14 @@ async fn handle_mkcol(
name: target_name.to_string(),
parent_id: Some(parent_folder.id.clone()),
};
// AuthZ audit #7 (2026-07-12): route `_with_perms` errors through
// `AppError::from` so authz denials surface as 404 (the anti-enum
// shape) instead of a `map_err → internal_error` 500. Also preserves
// `AlreadyExists → 409`, `QuotaExceeded → 507`, `InvalidInput → 400`.
folder_service
.create_folder_with_perms(dto, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create folder: {}", e)))?;
.map_err(AppError::from)?;
Ok(Response::builder()
.status(StatusCode::CREATED)
@@ -1076,20 +1085,22 @@ async fn handle_delete(
Resource::Folder(folder_uuid),
)
.await?;
// AuthZ audit #8 (2026-07-12): route service errors through
// `AppError::from` so authz denials surface as 404 (the
// anti-enum shape) instead of a `map_err → internal_error`
// 500 that gives a probing caller an "exists-but-denied"
// oracle. `move_to_trash` and `delete_folder_with_perms`
// both return `DomainError` and both call `authz.require`.
if let Some(trash_svc) = state.trash_service.as_ref() {
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to trash folder: {}", e))
})?;
.map_err(AppError::from)?;
} else {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete folder: {}", e))
})?;
.map_err(AppError::from)?;
}
}
ResolvedResource::File(file) => {
@@ -1103,21 +1114,18 @@ async fn handle_delete(
Resource::File(file_uuid),
)
.await?;
// AuthZ audit #8 (2026-07-12): same anti-enum fix as folder branch above.
if let Some(trash_svc) = state.trash_service.as_ref() {
trash_svc
.move_to_trash(&file.id, "file", user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to trash file: {}", e))
})?;
.map_err(AppError::from)?;
} else {
let file_mgmt = &state.applications.file_management_service;
file_mgmt
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete file: {}", e))
})?;
.map_err(AppError::from)?;
}
}
}
@@ -1195,6 +1203,12 @@ async fn handle_move(
// then proceed with the move. Trashing is fine: per RFC the source
// resource appears at the destination URI; what happens to the
// overwritten one is up to the server.
//
// AuthZ audit #9 (2026-07-12): route the `_with_perms` delete
// errors through `AppError::from` so authz denials surface as 404
// (anti-enum) instead of `map_err → internal_error` 500. Also
// preserves `QuotaExceeded → 507`, `AlreadyExists → 409`,
// `InvalidInput → 400`.
match existing {
ResolvedResource::File(existing_file) => {
let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| {
@@ -1211,12 +1225,7 @@ async fn handle_move(
file_mgmt
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to overwrite destination file: {}",
e
))
})?;
.map_err(AppError::from)?;
}
ResolvedResource::Folder(existing_folder) => {
let folder_uuid = Uuid::parse_str(&existing_folder.id).map_err(|_| {
@@ -1233,12 +1242,7 @@ async fn handle_move(
folder_service
.delete_folder_with_perms(&existing_folder.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to overwrite destination folder: {}",
e
))
})?;
.map_err(AppError::from)?;
}
}
}
@@ -1266,12 +1270,15 @@ async fn handle_move(
None => "",
};
// AuthZ audit #9 (2026-07-12): route `_with_perms` errors
// through `AppError::from` so authz denials surface as 404
// (anti-enum) instead of `map_err → internal_error` 500.
if src_parent_sub == dest_parent_sub {
// Same parent → rename.
file_mgmt
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
.map_err(AppError::from)?;
} else {
// Different parent → move.
let dest_parent = folder_service
@@ -1282,14 +1289,14 @@ async fn handle_move(
file_mgmt
.move_file_with_perms(&file.id, user.id, Some(dest_parent.id.clone()))
.await
.map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
.map_err(AppError::from)?;
// If the filename changed too, rename after move.
if file.name != dest_name {
file_mgmt
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
.map_err(AppError::from)?;
}
}
@@ -1331,6 +1338,9 @@ async fn handle_move(
None => "",
};
// AuthZ audit #9 (2026-07-12): route `_with_perms` errors
// through `AppError::from` so authz denials surface as 404
// (anti-enum) instead of `map_err → internal_error` 500.
if src_parent_sub == dest_parent_sub {
// Same parent → rename.
use crate::application::dtos::folder_dto::RenameFolderDto;
@@ -1343,7 +1353,7 @@ async fn handle_move(
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
.map_err(AppError::from)?;
} else {
// Different parent → move.
let dest_parent = folder_service
@@ -1361,7 +1371,7 @@ async fn handle_move(
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
.map_err(AppError::from)?;
// If the name changed too, rename.
if folder.name != dest_name {
@@ -1375,7 +1385,7 @@ async fn handle_move(
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
.map_err(AppError::from)?;
}
}