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
@@ -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)?;
}
}