security(upload): add permission to upload_file_streaming()

This commit is contained in:
Edouard Vanbelle
2026-07-17 01:21:03 +02:00
parent dd72b77c22
commit b1276938d4
5 changed files with 175 additions and 4 deletions
+19
View File
@@ -60,6 +60,25 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// `_with_perms` variant of `upload_file_streaming` — enforces
/// `Create` on the target folder before registering the row.
///
/// AuthZ audit #17 (2026-07-12): the chunked-upload `complete`
/// path called plain `upload_file_streaming` at finalize; a grant
/// revoked between session open and finalize stayed effective
/// until the caller landed the final chunk (up to 24h JWT TTL,
/// forever with app-passwords). Handlers now call this variant
/// so the engine re-checks at finalize regardless of how long
/// the session was open.
async fn upload_file_streaming_with_perms(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
blob: StoredBlob,
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// Replace the content of the file at `path` with an already-ingested
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
///
@@ -457,6 +457,44 @@ impl FileUploadUseCase for FileUploadService {
Ok(dto)
}
/// AuthZ audit #17 — `Create` on target folder is re-verified here
/// so mid-session grant revocations take effect at finalize. When
/// `folder_id` is `None` the write lands at drive-root; the drive
/// resolution for that case isn't plumbed through the chunked-
/// upload session (`UploadSession.folder_id` alone), so we fall
/// back to the pre-audit behaviour there. That drive-root path is
/// tracked separately as part of the D0 folder-id-walking work;
/// closing it here would require session-scoped drive_id.
async fn upload_file_streaming_with_perms(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
blob: StoredBlob,
caller_id: Uuid,
) -> Result<FileDto, DomainError> {
if let Some(fid) = folder_id.as_deref() {
let Some(authz) = &self.authorization else {
return Err(DomainError::internal_error(
"FileUpload",
"upload_file_streaming_with_perms called without authorization engine wired",
));
};
let folder_uuid = Uuid::parse_str(fid)
.map_err(|_| DomainError::not_found("Folder", fid.to_string()))?;
authz
.require(
Subject::User(caller_id),
Permission::Create,
Resource::Folder(folder_uuid),
)
.await?;
}
self.upload_file_streaming(name, folder_id, content_type, blob, caller_id)
.await
}
/// Swap the content of the file at `path` to an already-ingested blob,
/// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT).
///
+11
View File
@@ -511,6 +511,17 @@ impl FileUploadUseCase for StubFileUploadUseCase {
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
async fn upload_file_streaming_with_perms(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_blob: StoredBlob,
_caller_id: Uuid,
) -> Result<FileDto, DomainError> {
Ok(FileDto::default())
}
}
// ---------------------------------------------------------------------------
@@ -188,9 +188,14 @@ impl ChunkedUploadHandler {
// ── 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.
// upload service re-checks at finalize via
// `upload_file_streaming_with_perms` (AuthZ audit #17 fix,
// 2026-07-16) so a grant revoked mid-session is caught. This
// pre-check is the fail-fast: it avoids wasting client+server
// resources on chunks that will be rejected anyway. `None`
// means the write lands at drive-root — that path is currently
// unchecked (session doesn't carry `drive_id`; tracked with the
// folder-id-walking follow-up).
if let Some(ref fid) = request.folder_id
&& let Err(err) = state
.applications
@@ -441,9 +446,17 @@ impl ChunkedUploadHandler {
}
// Register the file row against the ingested blob.
//
// AuthZ audit #17 (2026-07-12): swapped `upload_file_streaming` →
// `upload_file_streaming_with_perms` so `Create` on the target
// folder is re-verified at finalize. Session creation already
// pre-checked (line ~198), but that was potentially hours or
// days ago; app-passwords keep sessions valid indefinitely.
// Without the finalize re-check, a grant revoked mid-session
// stayed effective until the last chunk landed.
let size = ingested.size;
match upload_service
.upload_file_streaming(
.upload_file_streaming_with_perms(
parts.filename.clone(),
parts.folder_id.clone(),
ingested.content_type.clone(),