diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index b55031f7..00829344 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::domain::entities::file::File; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use uuid::Uuid; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, @@ -75,6 +76,19 @@ pub struct FileDto { /// through `If-Match` / `If-None-Match` on download / mutation /// endpoints without a separate HEAD round-trip. pub etag: String, + + /// §14 provenance: user that originally created this file. + /// `None` when the referenced user has been deleted (FK is + /// `ON DELETE SET NULL`) or for stub/legacy files. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + + /// §14 provenance: user that performed the most recent mutation + /// that bumped `updated_at`. Authorship signal — distinct from + /// `owner_id`. `None` when the referenced user is deleted or for + /// stub/legacy files. + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, } impl From for FileDto { @@ -114,6 +128,8 @@ impl From for FileDto { sort_date: None, content_hash, etag, + created_by: parts.created_by, + updated_by: parts.updated_by, } } } @@ -171,6 +187,8 @@ impl FileDto { content_hash: String::new(), etag: String::new(), sort_date: None, + created_by: None, + updated_by: None, } } } diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index c712c37f..929215f4 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -86,6 +86,19 @@ pub struct FolderDto { /// pass it back through `If-Match` on rename / move endpoints /// without a separate HEAD round-trip. pub etag: String, + + /// §14 provenance: user that originally created this folder. + /// `None` when the referenced user has been deleted (FK is + /// `ON DELETE SET NULL`) or for stub/legacy folders. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + + /// §14 provenance: user that performed the most recent mutation + /// that bumped `updated_at`. Authorship signal — distinct from + /// `owner_id`. `None` when the referenced user is deleted or for + /// stub/legacy folders. + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, } impl From for FolderDto { @@ -107,6 +120,8 @@ impl From for FolderDto { icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), etag, + created_by: folder.created_by(), + updated_by: folder.updated_by(), } } } @@ -159,6 +174,8 @@ impl FolderDto { icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), etag: String::new(), + created_by: None, + updated_by: None, } } } diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index bbb67099..fa68438c 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -44,12 +44,20 @@ pub trait FileUploadUseCase: Send + Sync + 'static { /// Register a new file row pointing at an already-ingested blob. /// /// Takes ownership of the blob's reference (released on failure). + /// + /// `caller_id` is plumbed down into + /// `FileWritePort::save_file_with_blob` so the §14 `created_by` / + /// `updated_by` columns record the principal performing the upload — + /// not the parent folder's owner. D2 shared drives surface this + /// most clearly: Adam upload into Alice's folder must record + /// `created_by = adam.id`. async fn upload_file_streaming( &self, name: String, folder_id: Option, content_type: String, blob: StoredBlob, + caller_id: Uuid, ) -> Result; /// Replace the content of the file at `path` with an already-ingested @@ -61,6 +69,12 @@ pub trait FileUploadUseCase: Send + Sync + 'static { /// and the parent-folder resolution (`get_parent_folder_id`) — the /// handler is responsible for deriving it from its protocol context /// (NC chroot, native default-drive lookup, WOPI default-drive). + /// + /// `caller_id` is plumbed down into + /// `FileWritePort::update_file_content_with_blob` so the §14 + /// `updated_by` column reflects the principal that performed the + /// PUT — not the file's existing owner (D2 shared drives let + /// non-owners overwrite content). async fn update_file_streaming( &self, path: &str, @@ -68,6 +82,7 @@ pub trait FileUploadUseCase: Send + Sync + 'static { blob: StoredBlob, content_type: &str, modified_at: Option, + caller_id: Uuid, ) -> Result; } diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 52c7dbaf..bda99dac 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -284,6 +284,13 @@ pub trait FileWritePort: Send + Sync + 'static { /// /// Takes ownership of one blob reference: on any failure the reference /// is released before the error is returned. + /// + /// `caller_id` is stamped into both `created_by` and `updated_by` + /// (§14 provenance — authorship belongs to the caller, not to the + /// parent folder's owner). In D2 shared drives, a non-owner member + /// can upload into a folder owned by someone else; the previous + /// `created_by = parent.user_id` would have silently recorded the + /// wrong principal. async fn save_file_with_blob( &self, name: String, @@ -291,17 +298,29 @@ pub trait FileWritePort: Send + Sync + 'static { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result; - /// Moves a file to another folder. + /// Moves a file to another folder. `caller_id` is stamped into + /// `updated_by` alongside the `updated_at = NOW()` bump + /// (§14 provenance — authorship belongs to the caller, not to + /// the destination folder's owner). async fn move_file( &self, file_id: &str, target_folder_id: Option, + caller_id: Uuid, ) -> Result; - /// Renames a file (same folder, different name). - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; + /// Renames a file (same folder, different name). `caller_id` is + /// stamped into `updated_by` alongside the `updated_at = NOW()` + /// bump (§14 provenance). + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result; /// Deletes a file. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; @@ -315,24 +334,32 @@ pub trait FileWritePort: Send + Sync + 'static { /// Returns `(new_blob_hash, updated_at_epoch)` — everything a caller /// needs to rebuild the fresh entity/ETag from a `File` it already /// holds, without re-reading the row it just updated. + /// + /// `caller_id` is stamped into `updated_by` alongside the + /// `updated_at` bump (§14 provenance). async fn update_file_content_with_blob( &self, file_id: &str, blob_hash: &str, size: u64, modified_at: Option, + caller_id: Uuid, ) -> Result<(String, i64), DomainError>; /// Registers file metadata WITHOUT writing content to disk (write-behind). /// /// Returns `(File, PathBuf)` where `PathBuf` is the destination path for the /// deferred write that the `WriteBehindCache` will perform. + /// + /// `caller_id` is stamped into both `created_by` and `updated_by` + /// (§14 provenance — see `save_file_with_blob`). async fn register_file_deferred( &self, name: String, folder_id: Option, content_type: String, size: u64, + caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError>; /// Copies a file to a (possibly different) folder. @@ -344,11 +371,16 @@ pub trait FileWritePort: Send + Sync + 'static { /// the same folder always collides on the source's filename. WebDAV /// COPY uses this for the "same folder, different name" case (the /// classic `COPY /a.txt → /b.txt` pattern). + /// + /// `caller_id` is stamped into both `created_by` and `updated_by` + /// on the new row (§14 provenance — the caller authored this copy, + /// not the destination folder's owner). async fn copy_file( &self, file_id: &str, target_folder_id: Option, new_name: Option<&str>, + caller_id: Uuid, ) -> Result; /// Copies an entire folder subtree atomically using ltree. @@ -375,14 +407,17 @@ pub trait FileWritePort: Send + Sync + 'static { // ── Trash operations ── - /// Moves a file to the trash - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; + /// Moves a file to the trash. `caller_id` is stamped into + /// `updated_by` (§14 provenance). + async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError>; - /// Restores a file from the trash to its original location + /// Restores a file from the trash to its original location. + /// `caller_id` is stamped into `updated_by` (§14 provenance). async fn restore_from_trash( &self, file_id: &str, original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError>; /// Permanently deletes a file (used by the trash) diff --git a/src/application/services/delta_upload_service.rs b/src/application/services/delta_upload_service.rs index caf2238a..f67dc015 100644 --- a/src/application/services/delta_upload_service.rs +++ b/src/application/services/delta_upload_service.rs @@ -623,6 +623,7 @@ impl DeltaUploadService { Some(folder_id.clone()), content_type, blob, + caller_id, ) .await } diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 4386284b..9292833b 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -98,6 +98,7 @@ impl FileManagementService { &self, file_id: &str, folder_id: Option, + caller_id: Uuid, ) -> Result { info!( "Moving file with ID: {} to folder: {:?}", @@ -106,7 +107,7 @@ impl FileManagementService { let moved_file = self .file_repository - .move_file(file_id, folder_id) + .move_file(file_id, folder_id, caller_id) .await .map_err(|e| { error!("Error moving file (ID: {}): {}", file_id, e); @@ -128,6 +129,7 @@ impl FileManagementService { file_id: &str, target_folder_id: Option, new_name: Option<&str>, + caller_id: Uuid, ) -> Result { info!( "Copying file with ID: {} to folder: {:?} as {:?}", @@ -136,7 +138,7 @@ impl FileManagementService { let copied_file = self .file_repository - .copy_file(file_id, target_folder_id, new_name) + .copy_file(file_id, target_folder_id, new_name, caller_id) .await .map_err(|e| { error!("Error copying file (ID: {}): {}", file_id, e); @@ -157,7 +159,12 @@ impl FileManagementService { Ok(dto) } - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result { if let Err(reason) = validate_storage_name(new_name) { return Err(DomainError::validation_error(format!( "Invalid file name '{new_name}': {reason}" @@ -168,7 +175,7 @@ impl FileManagementService { let renamed_file = self .file_repository - .rename_file(file_id, new_name) + .rename_file(file_id, new_name, caller_id) .await .map_err(|e| { error!("Error renaming file (ID: {}): {}", file_id, e); @@ -253,7 +260,7 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id) .await?; - self.move_file(file_id, folder_id).await + self.move_file(file_id, folder_id, caller_id).await } async fn copy_file_with_perms( @@ -268,7 +275,7 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id) .await?; - self.copy_file(file_id, target_folder_id, new_name.as_deref()) + self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id) .await } @@ -280,7 +287,7 @@ impl FileManagementUseCase for FileManagementService { ) -> Result { self.require_file_perm(file_id, Permission::Update, caller_id) .await?; - self.rename_file(file_id, new_name).await + self.rename_file(file_id, new_name, caller_id).await } async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index c84239c7..612c1e0c 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -209,6 +209,7 @@ impl FileUploadService { size: metadata.size, is_new_blob: false, }, + caller_id, ) .await?; @@ -255,7 +256,7 @@ impl FileUploadService { let file = file_read.get_file(file_id).await?; let (new_hash, updated_at) = self .file_write - .update_file_content_with_blob(file_id, &blob.hash, blob.size, None) + .update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id) .await?; // The file maps to a different blob now — stale cached content must // never be served for the rest of its TTI window. @@ -326,10 +327,18 @@ impl FileUploadUseCase for FileUploadService { folder_id: Option, content_type: String, blob: StoredBlob, + caller_id: Uuid, ) -> Result { let file = self .file_write - .save_file_with_blob(name.clone(), folder_id, content_type, &blob.hash, blob.size) + .save_file_with_blob( + name.clone(), + folder_id, + content_type, + &blob.hash, + blob.size, + caller_id, + ) .await?; let dto = FileDto::from(file); info!( @@ -352,6 +361,7 @@ impl FileUploadUseCase for FileUploadService { blob: StoredBlob, content_type: &str, modified_at: Option, + caller_id: Uuid, ) -> Result { // Try to find the existing file first if let Some(file_read) = &self.file_read @@ -360,7 +370,13 @@ impl FileUploadUseCase for FileUploadService { let file_id = file.id().to_string(); let (new_hash, updated_at) = self .file_write - .update_file_content_with_blob(&file_id, &blob.hash, blob.size, modified_at) + .update_file_content_with_blob( + &file_id, + &blob.hash, + blob.size, + modified_at, + caller_id, + ) .await?; // Invalidate content cache — file content has changed. if let Some(cc) = &self.content_cache { @@ -428,6 +444,7 @@ impl FileUploadUseCase for FileUploadService { content_type.to_string(), &blob.hash, blob.size, + caller_id, ) .await?; let dto = FileDto::from(created); diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 48c99121..4a074a0a 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -234,7 +234,7 @@ impl FolderUseCase for FolderService { let folder = self .folder_storage - .create_folder(dto.name, dto.parent_id) + .create_folder(dto.name, dto.parent_id, caller_id) .await?; Ok(FolderDto::from(folder)) } @@ -489,7 +489,7 @@ impl FolderUseCase for FolderService { let folder = self .folder_storage - .rename_folder(id, dto.name) + .rename_folder(id, dto.name, caller_id) .await .map_err(|e| { DomainError::internal_error( @@ -541,7 +541,7 @@ impl FolderUseCase for FolderService { let parent_ref = dto.parent_id.as_deref(); let folder = self .folder_storage - .move_folder(id, parent_ref) + .move_folder(id, parent_ref, caller_id) .await .map_err(|e| { DomainError::internal_error( diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 385daf48..70a6909b 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -184,6 +184,7 @@ impl FileWritePort for MockFileWritePort { _content_type: String, _blob_hash: &str, _size: u64, + _caller_id: Uuid, ) -> Result { unimplemented!() } @@ -192,6 +193,7 @@ impl FileWritePort for MockFileWritePort { &self, file_id: &str, _target_folder_id: Option, + _caller_id: Uuid, ) -> Result { let files = self.files.lock().unwrap(); files @@ -200,7 +202,12 @@ impl FileWritePort for MockFileWritePort { .ok_or_else(|| DomainError::not_found("File", file_id.to_string())) } - async fn rename_file(&self, file_id: &str, _new_name: &str) -> Result { + async fn rename_file( + &self, + file_id: &str, + _new_name: &str, + _caller_id: Uuid, + ) -> Result { let files = self.files.lock().unwrap(); files .get(file_id) @@ -218,6 +225,7 @@ impl FileWritePort for MockFileWritePort { _blob_hash: &str, _size: u64, _modified_at: Option, + _caller_id: Uuid, ) -> Result<(String, i64), DomainError> { Ok((String::new(), 0)) } @@ -228,6 +236,7 @@ impl FileWritePort for MockFileWritePort { _folder_id: Option, _content_type: String, _size: u64, + _caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError> { unimplemented!() } @@ -237,11 +246,12 @@ impl FileWritePort for MockFileWritePort { _file_id: &str, _target_folder_id: Option, _new_name: Option<&str>, + _caller_id: Uuid, ) -> Result { unimplemented!() } - async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } @@ -249,6 +259,7 @@ impl FileWritePort for MockFileWritePort { &self, _file_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { Ok(()) } diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 6e869394..c747e70e 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -906,6 +906,7 @@ mod tests { &self, _name: String, _parent_id: Option, + _caller_id: uuid::Uuid, ) -> Result { unimplemented!() } @@ -980,6 +981,7 @@ mod tests { &self, _id: &str, _new_name: String, + _caller_id: Uuid, ) -> Result { unimplemented!() } @@ -988,6 +990,7 @@ mod tests { &self, _id: &str, _new_parent_id: Option<&str>, + _caller_id: Uuid, ) -> Result { unimplemented!() } @@ -1011,7 +1014,11 @@ mod tests { unimplemented!() } - async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> { + async fn move_to_trash( + &self, + _folder_id: &str, + _caller_id: Uuid, + ) -> Result<(), DomainError> { unimplemented!() } @@ -1019,6 +1026,7 @@ mod tests { &self, _folder_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { unimplemented!() } diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index a3bc766d..aa6a85d3 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -253,9 +253,10 @@ impl TrashUseCase for TrashService { } }; - // Then physically move the file to trash + // Then physically move the file to trash. + // §14: caller_id stamps `updated_by` on the trashed row. info!("Physically moving file to trash: {}", item_id); - match self.file_write_port.move_to_trash(item_id).await { + match self.file_write_port.move_to_trash(item_id, user_id).await { Ok(_) => { debug!("File physically moved to trash successfully: {}", item_id); } @@ -320,9 +321,10 @@ impl TrashUseCase for TrashService { } }; - // Then physically move the folder to trash + // Then physically move the folder to trash. + // §14: caller_id stamps `updated_by` on every cascade-trashed row. self.folder_storage_port - .move_to_trash(item_id) + .move_to_trash(item_id, user_id) .await .map_err(|e| { DomainError::new( @@ -391,7 +393,7 @@ impl TrashUseCase for TrashService { ); match self .file_write_port - .restore_from_trash(&file_id, &original_path) + .restore_from_trash(&file_id, &original_path, user_id) .await { Ok(_) => { @@ -431,7 +433,7 @@ impl TrashUseCase for TrashService { ); match self .folder_storage_port - .restore_from_trash(&folder_id, &original_path) + .restore_from_trash(&folder_id, &original_path, user_id) .await { Ok(_) => { @@ -831,6 +833,9 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), + // §14 provenance not selected by the trash listing query. + created_by: None, + updated_by: None, }; TrashResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -871,6 +876,9 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { sort_date: None, content_hash, etag, + // §14 provenance not selected by the trash listing query. + created_by: None, + updated_by: None, }; TrashResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index d1de1a2f..feda0b4e 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -139,7 +139,7 @@ where ) })?; self.file_write_port - .move_to_trash(item_id) + .move_to_trash(item_id, user_id) .await .map_err(|e| { DomainError::new( @@ -181,7 +181,7 @@ where ) })?; self.folder_storage_port - .move_to_trash(item_id) + .move_to_trash(item_id, user_id) .await .map_err(|e| { DomainError::new( @@ -215,7 +215,7 @@ where let original_path = item.original_path().to_string(); let result = self .file_write_port - .restore_from_trash(&file_id, &original_path) + .restore_from_trash(&file_id, &original_path, user_id) .await; if let Err(e) = result && !format!("{}", e).contains("not found") @@ -232,7 +232,7 @@ where let original_path = item.original_path().to_string(); let result = self .folder_storage_port - .restore_from_trash(&folder_id, &original_path) + .restore_from_trash(&folder_id, &original_path, user_id) .await; if let Err(e) = result && !format!("{}", e).contains("not found") @@ -566,6 +566,7 @@ impl FileWritePort for MockFileRepository { _content_type: String, _blob_hash: &str, _size: u64, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -574,6 +575,7 @@ impl FileWritePort for MockFileRepository { &self, _file_id: &str, _target_folder_id: Option, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -582,6 +584,7 @@ impl FileWritePort for MockFileRepository { &self, _file_id: &str, _new_name: &str, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -596,6 +599,7 @@ impl FileWritePort for MockFileRepository { _blob_hash: &str, _size: u64, _modified_at: Option, + _caller_id: Uuid, ) -> std::result::Result<(String, i64), DomainError> { Ok((String::new(), 0)) } @@ -606,6 +610,7 @@ impl FileWritePort for MockFileRepository { _folder_id: Option, _content_type: String, _size: u64, + _caller_id: Uuid, ) -> std::result::Result<(File, PathBuf), DomainError> { unimplemented!() } @@ -615,11 +620,16 @@ impl FileWritePort for MockFileRepository { _file_id: &str, _target_folder_id: Option, _new_name: Option<&str>, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } - async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { + async fn move_to_trash( + &self, + id: &str, + _caller_id: Uuid, + ) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); @@ -635,6 +645,7 @@ impl FileWritePort for MockFileRepository { &self, id: &str, _original_path: &str, + _caller_id: Uuid, ) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); @@ -698,6 +709,7 @@ impl FolderRepository for MockFolderRepository { &self, _name: String, _parent_id: Option, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -759,6 +771,7 @@ impl FolderRepository for MockFolderRepository { &self, _id: &str, _new_name: String, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -767,6 +780,7 @@ impl FolderRepository for MockFolderRepository { &self, _id: &str, _new_parent_id: Option<&str>, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -787,7 +801,11 @@ impl FolderRepository for MockFolderRepository { Ok(StoragePath::from_string("/")) } - async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { + async fn move_to_trash( + &self, + id: &str, + _caller_id: Uuid, + ) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); @@ -803,6 +821,7 @@ impl FolderRepository for MockFolderRepository { &self, id: &str, _original_path: &str, + _caller_id: Uuid, ) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); diff --git a/src/bin/load-seed.rs b/src/bin/load-seed.rs index 4266ea44..5a825220 100644 --- a/src/bin/load-seed.rs +++ b/src/bin/load-seed.rs @@ -235,10 +235,24 @@ async fn main() -> Result<(), Box> { .await?; let admin_root_id = admin_root.0; - let shared_subtree = - build_subtree(&pool, admin.id, admin_root_id, "shared_root", args.depth, args.fanout).await?; - let group_subtree = - build_subtree(&pool, admin.id, admin_root_id, "group_root", args.depth, args.fanout).await?; + let shared_subtree = build_subtree( + &pool, + admin.id, + admin_root_id, + "shared_root", + args.depth, + args.fanout, + ) + .await?; + let group_subtree = build_subtree( + &pool, + admin.id, + admin_root_id, + "group_root", + args.depth, + args.fanout, + ) + .await?; let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64; let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len(); diff --git a/src/common/stubs.rs b/src/common/stubs.rs index c48bc6bd..8698e4a4 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -165,6 +165,7 @@ impl FileWritePort for StubFileWritePort { _content_type: String, _blob_hash: &str, _size: u64, + _caller_id: Uuid, ) -> Result { Ok(File::default()) } @@ -173,6 +174,7 @@ impl FileWritePort for StubFileWritePort { &self, _file_id: &str, _target_folder_id: Option, + _caller_id: Uuid, ) -> Result { Ok(File::default()) } @@ -182,11 +184,17 @@ impl FileWritePort for StubFileWritePort { _file_id: &str, _target_folder_id: Option, _new_name: Option<&str>, + _caller_id: Uuid, ) -> Result { Ok(File::default()) } - async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { + async fn rename_file( + &self, + _file_id: &str, + _new_name: &str, + _caller_id: Uuid, + ) -> Result { Ok(File::default()) } @@ -200,6 +208,7 @@ impl FileWritePort for StubFileWritePort { _blob_hash: &str, _size: u64, _modified_at: Option, + _caller_id: Uuid, ) -> Result<(String, i64), DomainError> { Ok((String::new(), 0)) } @@ -210,11 +219,12 @@ impl FileWritePort for StubFileWritePort { _folder_id: Option, _content_type: String, _size: u64, + _caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError> { Ok((File::default(), PathBuf::from("/tmp/dummy"))) } - async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } @@ -222,6 +232,7 @@ impl FileWritePort for StubFileWritePort { &self, _file_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { Ok(()) } @@ -242,6 +253,7 @@ impl FolderRepository for StubFolderStoragePort { &self, _name: String, _parent_id: Option, + _caller_id: Uuid, ) -> Result { Ok(Folder::default()) } @@ -291,7 +303,12 @@ impl FolderRepository for StubFolderStoragePort { Ok((Vec::new(), Some(0))) } - async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { + async fn rename_folder( + &self, + _id: &str, + _new_name: String, + _caller_id: Uuid, + ) -> Result { Ok(Folder::default()) } @@ -299,6 +316,7 @@ impl FolderRepository for StubFolderStoragePort { &self, _id: &str, _new_parent_id: Option<&str>, + _caller_id: Uuid, ) -> Result { Ok(Folder::default()) } @@ -319,7 +337,7 @@ impl FolderRepository for StubFolderStoragePort { Ok(StoragePath::from_string("/")) } - async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, _folder_id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } @@ -327,6 +345,7 @@ impl FolderRepository for StubFolderStoragePort { &self, _folder_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { Ok(()) } @@ -500,6 +519,7 @@ impl FileUploadUseCase for StubFileUploadUseCase { _folder_id: Option, _content_type: String, _blob: StoredBlob, + _caller_id: Uuid, ) -> Result { Ok(FileDto::default()) } @@ -511,6 +531,7 @@ impl FileUploadUseCase for StubFileUploadUseCase { _blob: StoredBlob, _content_type: &str, _modified_at: Option, + _caller_id: Uuid, ) -> Result { Ok(FileDto::default()) } diff --git a/src/domain/entities/file.rs b/src/domain/entities/file.rs index 0c379040..9c9ba7ce 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -25,6 +25,10 @@ pub struct FileParts { pub owner_id: Option, /// BLAKE3 content hash. See [`File::content_hash`] for semantics. pub blob_hash: String, + /// §14 provenance: original creator. See [`File::created_by`]. + pub created_by: Option, + /// §14 provenance: most recent mutator. See [`File::updated_by`]. + pub updated_by: Option, } /** @@ -77,6 +81,18 @@ pub struct File { /// ETag (the ETag formula may grow to include `modified_at` etc., /// but `content_hash` remains the raw hash). blob_hash: String, + + /// User that originally created this file (§14 provenance). + /// Stamped at INSERT and never updated thereafter. `None` when + /// the referenced user has been deleted (FK is `ON DELETE SET + /// NULL`) or for stub/DTO-reconstructed files. + created_by: Option, + + /// User that performed the most recent mutation that bumped + /// `updated_at` (rename, move, content overwrite, trash, restore). + /// Authorship signal — distinct from ownership. `None` when the + /// referenced user is deleted or for stub/DTO-reconstructed files. + updated_by: Option, } // We no longer need this module, now we use a String directly @@ -95,6 +111,8 @@ impl Default for File { modified_at: 0, owner_id: None, blob_hash: String::new(), + created_by: None, + updated_by: None, } } } @@ -134,6 +152,8 @@ impl File { modified_at: now, owner_id: None, blob_hash: String::new(), + created_by: None, + updated_by: None, }) } @@ -166,6 +186,8 @@ impl File { modified_at, owner_id: None, blob_hash: String::new(), + created_by: None, + updated_by: None, }) } @@ -207,6 +229,40 @@ impl File { modified_at: u64, owner_id: Option, blob_hash: String, + ) -> FileResult { + Self::with_timestamps_blob_hash_and_provenance( + id, + name, + storage_path, + size, + mime_type, + folder_id, + created_at, + modified_at, + owner_id, + blob_hash, + None, + None, + ) + } + + /// Full constructor including the §14 provenance columns + /// (`created_by` / `updated_by`). PG-row callers use this to + /// preserve authorship across reconstruction. + #[allow(clippy::too_many_arguments)] + pub fn with_timestamps_blob_hash_and_provenance( + id: String, + name: String, + storage_path: StoragePath, + size: u64, + mime_type: String, + folder_id: Option, + created_at: u64, + modified_at: u64, + owner_id: Option, + blob_hash: String, + created_by: Option, + updated_by: Option, ) -> FileResult { let name = normalize_storage_name(&name); if let Err(reason) = validate_storage_name(&name) { @@ -228,6 +284,8 @@ impl File { modified_at, owner_id, blob_hash, + created_by, + updated_by, }) } @@ -248,6 +306,8 @@ impl File { modified_at: self.modified_at, owner_id: self.owner_id, blob_hash: self.blob_hash, + created_by: self.created_by, + updated_by: self.updated_by, } } @@ -351,6 +411,21 @@ impl File { self.owner_id } + /// User that originally created this file (§14 provenance). + /// `None` when the referenced user has been deleted + /// (FK is `ON DELETE SET NULL`) or for stub/DTO entities. + pub fn created_by(&self) -> Option { + self.created_by + } + + /// User that performed the most recent mutation that bumped + /// `updated_at`. Authorship signal — distinct from ownership. + /// `None` when the referenced user is deleted or for + /// stub/DTO entities. + pub fn updated_by(&self) -> Option { + self.updated_by + } + #[allow(clippy::too_many_arguments)] pub fn from_dto( id: String, @@ -382,6 +457,10 @@ impl File { modified_at, owner_id: None, blob_hash: String::new(), + // DTO round-trips don't carry provenance; callers needing + // it must reload from the repository. + created_by: None, + updated_by: None, } } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 3e731291..53242dc3 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -50,6 +50,20 @@ pub struct Folder { /// HTTP ETag emitted in PROPFIND/GET/HEAD responses — see /// [`Folder::etag`] for the formula and rationale. tree_modified_at: u64, + + /// User that originally created this folder. Stamped at INSERT + /// from the caller's id and never updated afterwards (provenance, + /// not ownership — see §14 of the Drive plan). `None` when the + /// referenced user is later deleted (FK is `ON DELETE SET NULL`) + /// or for stub/DTO-reconstructed folders that never touched the DB. + created_by: Option, + + /// User that performed the most recent mutation that touched + /// `updated_at` (rename, move, trash, restore, content overwrite). + /// Authorship signal — does NOT propagate via the tree-ETag flush + /// trigger. `None` when the referenced user is deleted or for + /// stub/DTO-reconstructed folders. + updated_by: Option, } // We no longer need this module, now we use a String directly @@ -67,6 +81,8 @@ impl Default for Folder { created_at: 0, modified_at: 0, tree_modified_at: 0, + created_by: None, + updated_by: None, } } } @@ -119,6 +135,10 @@ impl Folder { created_at: now, modified_at: now, tree_modified_at: now, + // Provenance is unknown for in-memory construction; the DB + // reconstruction path supplies real values. + created_by: None, + updated_by: None, }) } @@ -179,7 +199,10 @@ impl Folder { /// `tree_modified_at` comes from the trigger-maintained column on /// `storage.folders` and feeds [`Folder::etag`]. `drive_id` is the /// post-D0 `storage.folders.drive_id NOT NULL` column — every - /// path-based lookup scopes by this axis. + /// path-based lookup scopes by this axis. `created_by` / + /// `updated_by` are the §14 provenance columns; both are nullable + /// because the M1 FK is `ON DELETE SET NULL` (a deleted user + /// leaves authored rows in place). #[allow(clippy::too_many_arguments)] pub fn with_timestamps_and_tree( id: String, @@ -191,6 +214,38 @@ impl Folder { created_at: u64, modified_at: u64, tree_modified_at: u64, + ) -> FolderResult { + Self::with_timestamps_tree_and_provenance( + id, + name, + storage_path, + parent_id, + owner_id, + drive_id, + created_at, + modified_at, + tree_modified_at, + None, + None, + ) + } + + /// Full constructor including the §14 provenance columns + /// (`created_by` / `updated_by`). Direct PG-row callers use this + /// to preserve authorship through the entity layer. + #[allow(clippy::too_many_arguments)] + pub fn with_timestamps_tree_and_provenance( + id: String, + name: String, + storage_path: StoragePath, + parent_id: Option, + owner_id: Option, + drive_id: Uuid, + created_at: u64, + modified_at: u64, + tree_modified_at: u64, + created_by: Option, + updated_by: Option, ) -> FolderResult { let name = normalize_storage_name(&name); if let Err(reason) = validate_storage_name(&name) { @@ -210,6 +265,8 @@ impl Folder { created_at, modified_at, tree_modified_at, + created_by, + updated_by, }) } @@ -253,6 +310,22 @@ impl Folder { self.drive_id } + /// User that originally created this folder (§14 provenance). + /// `None` when the referenced user has been deleted + /// (FK is `ON DELETE SET NULL`) or for in-memory/DTO-reconstructed + /// entities. + pub fn created_by(&self) -> Option { + self.created_by + } + + /// User that performed the most recent mutation that bumped + /// `updated_at`. Authorship signal — distinct from ownership. + /// `None` when the referenced user has been deleted or for + /// in-memory/DTO-reconstructed entities. + pub fn updated_by(&self) -> Option { + self.updated_by + } + /// Latest descendant-write timestamp. Statement-level Postgres /// triggers enqueue every file/folder write into /// `storage.tree_etag_dirty`; the background `TreeEtagFlushService` @@ -348,6 +421,10 @@ impl Folder { created_at, modified_at, tree_modified_at: modified_at, + // DTO round-trips through this constructor lose + // provenance; callers that need it reload through the repo. + created_by: None, + updated_by: None, } } @@ -391,6 +468,10 @@ impl Folder { // ancestors' listings now show a new name, so the // collection has materially changed. tree_modified_at: now, + // Provenance is preserved across the in-memory rebuild; + // real persisted updates re-read from the DB. + created_by: self.created_by, + updated_by: self.updated_by, }) } @@ -425,6 +506,8 @@ impl Folder { created_at: self.created_at, modified_at: now, tree_modified_at: now, + created_by: self.created_by, + updated_by: self.updated_by, }) } diff --git a/src/domain/repositories/file_repository.rs b/src/domain/repositories/file_repository.rs index 60bee7c0..5bc4ea17 100644 --- a/src/domain/repositories/file_repository.rs +++ b/src/domain/repositories/file_repository.rs @@ -78,6 +78,9 @@ pub trait FileWriteRepository: Send + Sync + 'static { /// Registers a file row pointing at a blob already stored in the /// content-addressable chunk store (one blob reference is consumed). + /// + /// `caller_id` stamps both `created_by` and `updated_by` + /// (§14 provenance). async fn save_file_with_blob( &self, name: String, @@ -85,17 +88,26 @@ pub trait FileWriteRepository: Send + Sync + 'static { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result; - /// Moves a file to another folder. + /// Moves a file to another folder. `caller_id` stamps `updated_by` + /// in the same UPDATE that bumps `updated_at` (§14 provenance). async fn move_file( &self, file_id: &str, target_folder_id: Option, + caller_id: Uuid, ) -> Result; - /// Renames a file (same folder, different name). - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; + /// Renames a file (same folder, different name). `caller_id` + /// stamps `updated_by` in the same UPDATE (§14 provenance). + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result; /// Deletes a file. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; @@ -108,24 +120,31 @@ pub trait FileWriteRepository: Send + Sync + 'static { /// /// Returns `(File, PathBuf)` where `PathBuf` is the destination path for /// the deferred write that the `WriteBehindCache` will perform. + /// + /// `caller_id` stamps both `created_by` and `updated_by` + /// (§14 provenance). async fn register_file_deferred( &self, name: String, folder_id: Option, content_type: String, size: u64, + caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError>; // ── Trash operations ── - /// Moves a file to the trash - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; + /// Moves a file to the trash. `caller_id` stamps `updated_by` + /// (§14 provenance). + async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError>; - /// Restores a file from the trash to its original location + /// Restores a file from the trash to its original location. + /// `caller_id` stamps `updated_by` (§14 provenance). async fn restore_from_trash( &self, file_id: &str, original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError>; /// Permanently deletes a file (used by the trash) diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 80ebde96..c9afdb46 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -18,11 +18,18 @@ use uuid::Uuid; /// Defines the CRUD and management operations required for /// the Folder entity in the storage system. pub trait FolderRepository: Send + Sync + 'static { - /// Creates a new folder + /// Creates a new folder. + /// + /// `caller_id` is stamped into `created_by` and `updated_by` + /// (D0 §14 provenance — authorship belongs to whoever issued the + /// create, not to the parent folder's owner). Pre-D2 they're + /// silently equivalent (only the owner can write); D2 ships + /// shared drives where this distinction matters. async fn create_folder( &self, name: String, parent_id: Option, + caller_id: Uuid, ) -> Result; /// Gets a folder by its ID @@ -74,14 +81,23 @@ pub trait FolderRepository: Send + Sync + 'static { include_total: bool, ) -> Result<(Vec, Option), DomainError>; - /// Renames a folder - async fn rename_folder(&self, id: &str, new_name: String) -> Result; + /// Renames a folder. `caller_id` is stamped into `updated_by` + /// alongside the `updated_at = NOW()` bump (§14 provenance). + async fn rename_folder( + &self, + id: &str, + new_name: String, + caller_id: Uuid, + ) -> Result; - /// Moves a folder to another parent + /// Moves a folder to another parent. `caller_id` is stamped into + /// `updated_by` alongside the `updated_at = NOW()` bump + /// (§14 provenance). async fn move_folder( &self, id: &str, new_parent_id: Option<&str>, + caller_id: Uuid, ) -> Result; /// Deletes a folder @@ -102,14 +118,19 @@ pub trait FolderRepository: Send + Sync + 'static { // ── Trash operations ── - /// Moves a folder to the trash - async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError>; + /// Moves a folder to the trash. `caller_id` is stamped into + /// `updated_by` for the root row and every cascade-trashed + /// descendant (§14 provenance). + async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError>; - /// Restores a folder from the trash to its original location + /// Restores a folder from the trash to its original location. + /// `caller_id` is stamped into `updated_by` for the root row and + /// every cascade-restored descendant (§14 provenance). async fn restore_from_trash( &self, folder_id: &str, original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError>; /// Permanently deletes a folder (used by the trash) diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index ef7ed650..cc4a7428 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -19,6 +19,8 @@ type MediaFileRow = ( i64, // updated_at String, // blob_hash Option, // user_id + Option, // created_by (§14 provenance) + Option, // updated_by (§14 provenance) i64, // sort_date Option, // width Option, // height @@ -42,7 +44,9 @@ use crate::infrastructure::services::dedup_service::DedupService; use uuid::Uuid; /// Type alias for file metadata rows from SQL queries. -/// Fields: id, name, folder_id, folder_path, size, mime_type, created_at, updated_at, blob_hash, user_id +/// Fields: id, name, folder_id, folder_path, size, mime_type, +/// created_at, updated_at, blob_hash, user_id, created_by, updated_by. +/// `created_by` / `updated_by` are the §14 provenance columns. type FileRow = ( String, String, @@ -54,6 +58,8 @@ type FileRow = ( i64, String, Option, + Option, + Option, ); /// Append the optional type/date/size filters from `criteria` to @@ -228,7 +234,8 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id \ + fi.user_id, \ + fi.created_by, fi.updated_by \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ WHERE {where_clause}" @@ -248,8 +255,10 @@ impl FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -277,7 +286,8 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id \ + fi.user_id, \ + fi.created_by, fi.updated_by \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ WHERE fi.id = ANY($1) AND NOT fi.is_trashed", @@ -291,8 +301,10 @@ impl FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -357,9 +369,11 @@ impl FileBlobReadRepository { modified_at: i64, blob_hash: String, owner_id: Option, + created_by: Option, + updated_by: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_and_blob_hash( + File::with_timestamps_blob_hash_and_provenance( id, name, storage_path, @@ -370,6 +384,8 @@ impl FileBlobReadRepository { modified_at as u64, owner_id, blob_hash, + created_by, + updated_by, ) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}"))) } @@ -428,6 +444,7 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, fi.user_id, + fi.created_by, fi.updated_by, EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, fm.width, fm.height FROM storage.files fi @@ -453,9 +470,9 @@ impl FileBlobReadRepository { let mut sort_dates = Vec::with_capacity(rows.len()); let mut dims = Vec::with_capacity(rows.len()); - for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows { + for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, sd, w, h) in rows { files.push(Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, )?); sort_dates.push(sd); dims.push((w, h)); @@ -530,6 +547,8 @@ impl FileReadPort for FileBlobReadRepository { i64, // updated_at String, // blob_hash Option, // user_id (owner) + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -538,7 +557,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid AND NOT fi.is_trashed @@ -555,7 +575,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, ) } @@ -576,6 +596,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -584,7 +606,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid @@ -598,7 +621,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, ) } @@ -616,6 +639,8 @@ impl FileReadPort for FileBlobReadRepository { i64, // updated_at String, // blob_hash Option, // user_id (owner) + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -624,7 +649,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid @@ -643,7 +669,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, ) } @@ -657,7 +683,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 AND NOT fi.is_trashed @@ -675,7 +702,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 @@ -689,8 +717,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -711,7 +741,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 AND NOT fi.is_trashed @@ -731,7 +762,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 @@ -747,8 +779,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -777,7 +811,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 AND NOT fi.is_trashed @@ -798,7 +833,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 @@ -815,8 +851,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -839,7 +877,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 AND NOT fi.is_trashed @@ -862,7 +901,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 @@ -883,8 +923,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -1035,6 +1077,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -1043,7 +1087,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.name = $1 AND fi.folder_id IS NULL @@ -1072,6 +1117,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -1080,7 +1127,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fo.path = $1 AND fi.name = $2 @@ -1097,7 +1145,7 @@ impl FileReadPort for FileBlobReadRepository { match row { Some(r) => Ok(Some(Self::row_to_file( - r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, + r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11, )?)), None => Ok(None), } @@ -1118,6 +1166,7 @@ impl FileReadPort for FileBlobReadRepository { let mut row_stream = sqlx::query_as::<_, ( String, String, Option, Option, i64, String, i64, i64, String, Option, + Option, Option, )>( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -1125,7 +1174,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) @@ -1139,9 +1189,9 @@ impl FileReadPort for FileBlobReadRepository { while let Some(row) = row_stream.try_next().await.map_err(|e| { DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}")) })? { - let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) = row; + let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub) = row; let file = FileBlobReadRepository::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, )?; yield file; } @@ -1205,6 +1255,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ fi.user_id, \ + fi.created_by, fi.updated_by, \ COUNT(*) OVER() AS total_count \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ @@ -1227,6 +1278,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) i64, ), >(&sql) @@ -1249,13 +1302,15 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; // total_count is the same in every row; 0 when result set is empty. - let total_count = rows.first().map_or(0, |r| r.10) as usize; + let total_count = rows.first().map_or(0, |r| r.12) as usize; let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -1331,6 +1386,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ fi.user_id, \ + fi.created_by, fi.updated_by, \ COUNT(*) OVER() AS total_count \ FROM storage.files fi \ JOIN storage.folders fo ON fo.id = fi.folder_id \ @@ -1353,6 +1409,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) i64, ), >(&sql) @@ -1373,13 +1431,15 @@ impl FileReadPort for FileBlobReadRepository { DomainError::internal_error("FileBlobRead", format!("subtree search: {e}")) })?; - let total_count = rows.first().map_or(0, |r| r.10) as usize; + let total_count = rows.first().map_or(0, |r| r.12) as usize; let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -1421,7 +1481,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 @@ -1450,7 +1511,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + 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 @@ -1475,8 +1537,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index 6315f441..3bb60761 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -115,9 +115,11 @@ impl FileBlobWriteRepository { modified_at: i64, owner_id: Option, blob_hash: String, + created_by: Option, + updated_by: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_and_blob_hash( + File::with_timestamps_blob_hash_and_provenance( id, name, storage_path, @@ -128,6 +130,8 @@ impl FileBlobWriteRepository { modified_at as u64, owner_id, blob_hash, + created_by, + updated_by, ) .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}"))) } @@ -171,12 +175,18 @@ impl FileBlobWriteRepository { /// `(new_hash, updated_at_epoch)` on success — the effective timestamp /// is returned so callers can rebuild the fresh entity without /// re-reading the row. + /// + /// §14: `updated_by = $5` (caller_id). The caller mutated this + /// row — not the row's owner. D2 shared drives let non-owners + /// overwrite content; the previous `updated_by = f.user_id` would + /// have silently recorded the wrong principal. async fn swap_blob_hash( &self, file_id: &str, new_hash: &str, new_size: i64, modified_at: Option, + caller_id: Uuid, ) -> Result<(String, i64), DomainError> { // Atomic CTE: capture old hash then update in one round-trip, no TOCTOU. // Deadlock victims (40P01) retry before the compensation below runs — @@ -190,7 +200,7 @@ impl FileBlobWriteRepository { UPDATE storage.files f SET blob_hash = $1, size = $2, updated_at = COALESCE(to_timestamp($4), NOW()), - updated_by = f.user_id + updated_by = $5 FROM old WHERE f.id = old.id RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint @@ -200,6 +210,7 @@ impl FileBlobWriteRepository { .bind(new_size) .bind(file_id) .bind(modified_at.map(|t| t as f64)) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) }) .await @@ -245,6 +256,12 @@ impl FileBlobWriteRepository { /// Register a file row pointing at a blob already stored in the chunk /// store (the upload-ingest layer streamed the content in). Consumes the /// caller's blob reference: any failure releases it before returning. + /// + /// §14: `created_by = $7 = updated_by = caller_id` — authorship + /// belongs to the principal performing the upload, not to the parent + /// folder's owner. In D2 shared drives a non-owner member can upload + /// into a folder Alice owns; binding `parent.user_id` would have + /// silently recorded Alice as the author. async fn save_file_with_blob_impl( &self, name: String, @@ -252,6 +269,7 @@ impl FileBlobWriteRepository { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result { // Root files have no parent folder to derive an owner from — keep the // previous resolve_user_id(None) contract (release the ref, error out). @@ -282,7 +300,7 @@ impl FileBlobWriteRepository { // (a retried INSERT can legitimately lose to a concurrent identical // upload). let result = retry_on_deadlock("files.insert", || { - sqlx::query_as::<_, (String, Uuid, String, i64, i64)>( + sqlx::query_as::<_, (String, Uuid, String, i64, i64, Option, Option)>( r#" WITH parent AS ( SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid @@ -291,13 +309,15 @@ impl FileBlobWriteRepository { (name, folder_id, user_id, drive_id, blob_hash, size, mime_type, category_order, created_by, updated_by) SELECT $1, parent.id, parent.user_id, parent.drive_id, $3, $4, - $5, $6, parent.user_id, parent.user_id + $5, $6, $7, $7 FROM parent RETURNING id::text, user_id, (SELECT path FROM parent), EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + created_by, + updated_by "#, ) .bind(&name) @@ -306,44 +326,46 @@ impl FileBlobWriteRepository { .bind(size as i64) .bind(&content_type) .bind(category_order_for(&name, &content_type)) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) }) .await; - let (id, user_id, folder_path, created_at, updated_at) = match result { - Ok(Some(row)) => row, - Ok(None) => { - if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { - tracing::error!( - "Blob orphaned after missing parent folder — hash: {}, err: {}", - &blob_hash[..12], - rollback_err - ); + let (id, user_id, folder_path, created_at, updated_at, created_by, updated_by) = + match result { + Ok(Some(row)) => row, + Ok(None) => { + if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { + tracing::error!( + "Blob orphaned after missing parent folder — hash: {}, err: {}", + &blob_hash[..12], + rollback_err + ); + } + return Err(DomainError::not_found("Folder", fid)); } - return Err(DomainError::not_found("Folder", fid)); - } - Err(e) => { - if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { - tracing::error!( - "Blob orphaned after failed INSERT — hash: {}, err: {}", - &blob_hash[..12], - rollback_err - ); - } - if let sqlx::Error::Database(ref db_err) = e - && db_err.code().as_deref() == Some("23505") - { - return Err(DomainError::already_exists( - "File", - format!("'{name}' already exists in this folder"), + Err(e) => { + if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { + tracing::error!( + "Blob orphaned after failed INSERT — hash: {}, err: {}", + &blob_hash[..12], + rollback_err + ); + } + if let sqlx::Error::Database(ref db_err) = e + && db_err.code().as_deref() == Some("23505") + { + return Err(DomainError::already_exists( + "File", + format!("'{name}' already exists in this folder"), + )); + } + return Err(DomainError::internal_error( + "FileBlobWrite", + format!("insert: {e}"), )); } - return Err(DomainError::internal_error( - "FileBlobWrite", - format!("insert: {e}"), - )); - } - }; + }; tracing::info!( "📡 STREAMING WRITE: {} ({} bytes, hash: {})", @@ -363,6 +385,8 @@ impl FileBlobWriteRepository { updated_at, Some(user_id), blob_hash.to_string(), + created_by, + updated_by, ) } } @@ -375,8 +399,9 @@ impl FileWritePort for FileBlobWriteRepository { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result { - self.save_file_with_blob_impl(name, folder_id, content_type, blob_hash, size) + self.save_file_with_blob_impl(name, folder_id, content_type, blob_hash, size, caller_id) .await } @@ -384,9 +409,30 @@ impl FileWritePort for FileBlobWriteRepository { &self, file_id: &str, target_folder_id: Option, + caller_id: Uuid, ) -> Result { - // If moving to a different folder, get the new user_id (must be same user) - let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( + // If moving to a different folder, get the new user_id (must be same user). + // + // §14: `updated_by = $3` (caller_id) — the caller mutated this + // row. The previous COALESCE derived authorship from the + // destination folder's owner, which is wrong: dest's user_id + // has no claim to authorship of the file's content. D2 shared + // drives surface this most starkly (Alice moves Bob's file + // into Charlie's drive — `updated_by` must be Alice). + let row = sqlx::query_as::< + _, + ( + String, + String, + Option, + i64, + String, + i64, + i64, + Option, + Option, + ), + >( r#" WITH dest AS ( SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid @@ -396,15 +442,17 @@ impl FileWritePort for FileBlobWriteRepository { user_id = COALESCE((SELECT user_id FROM dest), f.user_id), drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id), updated_at = NOW(), - updated_by = COALESCE((SELECT user_id FROM dest), f.user_id) + updated_by = $3 WHERE f.id = $2::uuid AND NOT f.is_trashed RETURNING f.id::text, f.name, f.folder_id::text, f.size, f.mime_type, EXTRACT(EPOCH FROM f.created_at)::bigint, - EXTRACT(EPOCH FROM f.updated_at)::bigint + EXTRACT(EPOCH FROM f.updated_at)::bigint, + f.created_by, f.updated_by "#, ) .bind(&target_folder_id) .bind(file_id) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))? @@ -422,6 +470,8 @@ impl FileWritePort for FileBlobWriteRepository { row.6, None, String::new(), + row.7, + row.8, ) } @@ -430,9 +480,16 @@ impl FileWritePort for FileBlobWriteRepository { file_id: &str, target_folder_id: Option, new_name: Option<&str>, + caller_id: Uuid, ) -> Result { // Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count. // Single round-trip; blob content is NOT copied (dedup makes this zero-copy). + // + // §14: `created_by = $4 = updated_by = caller_id` — the caller + // authored this copy. The previous binding used + // `dest_folder.user_id` which silently recorded the destination + // folder's owner as the author when Adam copied a file into + // Alice's folder. let target_fid = target_folder_id.clone(); let rename_to = new_name.map(|s| s.to_string()); @@ -448,6 +505,8 @@ impl FileWritePort for FileBlobWriteRepository { i64, i64, String, + Option, + Option, ), >( r#" @@ -480,13 +539,15 @@ impl FileWritePort for FileBlobWriteRepository { src.size, src.mime_type, src.category_order, - dest_folder.user_id, - dest_folder.user_id + $4, + $4 FROM src, dest_folder RETURNING id::text, name, folder_id::text, size, mime_type, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - blob_hash + blob_hash, + created_by, + updated_by ) SELECT * FROM new_file "#, @@ -494,6 +555,7 @@ impl FileWritePort for FileBlobWriteRepository { .bind(file_id) .bind(&target_fid) .bind(&rename_to) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) }) .await @@ -539,22 +601,45 @@ impl FileWritePort for FileBlobWriteRepository { row.6, None, row.7, + row.8, + row.9, ) } - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { - let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result { + // §14: `updated_by = $3` (caller_id), see move_file. + let row = sqlx::query_as::< + _, + ( + String, + String, + Option, + i64, + String, + i64, + i64, + Option, + Option, + ), + >( r#" UPDATE storage.files - SET name = $1, updated_at = NOW(), updated_by = user_id + SET name = $1, updated_at = NOW(), updated_by = $3 WHERE id = $2::uuid AND NOT is_trashed RETURNING id::text, name, folder_id::text, size, mime_type, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + created_by, updated_by "#, ) .bind(new_name) .bind(file_id) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| { @@ -579,6 +664,8 @@ impl FileWritePort for FileBlobWriteRepository { row.6, None, String::new(), + row.7, + row.8, ) } @@ -608,12 +695,13 @@ impl FileWritePort for FileBlobWriteRepository { blob_hash: &str, size: u64, modified_at: Option, + caller_id: Uuid, ) -> Result<(String, i64), DomainError> { // The content was already ingested into the chunk store by the // upload-ingest layer; swap_blob_hash consumes its reference and // releases it on failure. let swapped = self - .swap_blob_hash(file_id, blob_hash, size as i64, modified_at) + .swap_blob_hash(file_id, blob_hash, size as i64, modified_at, caller_id) .await?; // The file now maps to a different blob — drop the read-side cache // entry so streaming downloads cannot serve the previous content @@ -628,6 +716,7 @@ impl FileWritePort for FileBlobWriteRepository { folder_id: Option, content_type: String, size: u64, + caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError> { let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?; @@ -635,16 +724,22 @@ impl FileWritePort for FileBlobWriteRepository { // The write-behind cache will call update_file_content later. let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + // §14: `created_by = $9 = updated_by = caller_id`. The legacy + // `user_id` column (dropped in D7) stays bound to the parent + // folder's owner; only the two provenance columns flip to the + // caller — see save_file_with_blob_impl. let row = retry_on_deadlock("files.insert_deferred", || { - sqlx::query_as::<_, (String, i64, i64)>( + sqlx::query_as::<_, (String, i64, i64, Option, Option)>( r#" INSERT INTO storage.files (name, folder_id, user_id, drive_id, blob_hash, size, mime_type, category_order, created_by, updated_by) - VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $3, $3) + VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $9) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + created_by, + updated_by "#, ) .bind(&name) @@ -655,6 +750,7 @@ impl FileWritePort for FileBlobWriteRepository { .bind(size as i64) .bind(&content_type) .bind(category_order_for(&name, &content_type)) + .bind(caller_id) .fetch_one(self.pool.as_ref()) }) .await @@ -672,6 +768,8 @@ impl FileWritePort for FileBlobWriteRepository { row.2, Some(user_id), String::new(), + row.3, + row.4, )?; // The target_path is not meaningful for blob storage (content goes to .blobs/) @@ -683,7 +781,8 @@ impl FileWritePort for FileBlobWriteRepository { // ── Trash operations ── - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError> { + // §14: `updated_by = $2` (caller_id), see move_file. let result = sqlx::query( r#" UPDATE storage.files @@ -691,11 +790,12 @@ impl FileWritePort for FileBlobWriteRepository { trashed_at = NOW(), original_folder_id = folder_id, updated_at = NOW(), - updated_by = user_id + updated_by = $2 WHERE id = $1::uuid AND NOT is_trashed "#, ) .bind(file_id) + .bind(caller_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?; @@ -710,7 +810,9 @@ impl FileWritePort for FileBlobWriteRepository { &self, file_id: &str, _original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError> { + // §14: `updated_by = $2` (caller_id), see move_file. let result = sqlx::query( r#" UPDATE storage.files @@ -719,11 +821,12 @@ impl FileWritePort for FileBlobWriteRepository { folder_id = COALESCE(original_folder_id, folder_id), original_folder_id = NULL, updated_at = NOW(), - updated_by = user_id + updated_by = $2 WHERE id = $1::uuid AND is_trashed "#, ) .bind(file_id) + .bind(caller_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?; diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 66b6e731..60994f66 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -22,11 +22,12 @@ use crate::domain::services::path_service::StoragePath; /// Type alias for folder metadata rows from SQL queries. /// Tuple order: id, name, path, parent_id, user_id, drive_id, -/// created_at, modified_at, tree_modified_at. The trailing -/// `tree_modified_at` feeds [`Folder::etag`] — every SELECT here -/// must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. +/// created_at, modified_at, tree_modified_at, created_by, updated_by. +/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every +/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. /// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based -/// lookups. +/// lookups. `created_by` / `updated_by` are the §14 provenance +/// columns, nullable because the FK is `ON DELETE SET NULL`. type FolderRow = ( String, String, @@ -37,10 +38,12 @@ type FolderRow = ( i64, i64, i64, + Option, + Option, ); /// Type alias for paginated folder rows (includes total_count as -/// the last element after `tree_modified_at`). +/// the last element after the §14 provenance columns). type FolderRowPaginated = ( String, String, @@ -51,10 +54,13 @@ type FolderRowPaginated = ( i64, i64, i64, + Option, + Option, i64, ); /// Type alias for folder rows with optional user_id. +/// Includes the §14 provenance columns `created_by` / `updated_by`. type FolderRowOptUser = ( String, String, @@ -65,6 +71,8 @@ type FolderRowOptUser = ( i64, i64, i64, + Option, + Option, ); /// PostgreSQL-backed folder repository. @@ -98,7 +106,9 @@ impl FolderDbRepository { /// Convert a database row into a `Folder` domain entity. /// /// The `path` comes directly from the materialized `path` column — no - /// extra queries needed. + /// extra queries needed. `created_by` / `updated_by` carry the + /// §14 provenance signal through the entity layer; both are + /// `Option` because the FK is `ON DELETE SET NULL`. #[allow(clippy::too_many_arguments)] fn row_to_folder( id: String, @@ -110,9 +120,11 @@ impl FolderDbRepository { created_at: i64, modified_at: i64, tree_modified_at: i64, + created_by: Option, + updated_by: Option, ) -> Result { let storage_path = StoragePath::from_string(&path); - Folder::with_timestamps_and_tree( + Folder::with_timestamps_tree_and_provenance( id, name, storage_path, @@ -122,6 +134,8 @@ impl FolderDbRepository { created_at as u64, modified_at as u64, tree_modified_at as u64, + created_by, + updated_by, ) .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) } @@ -139,10 +153,11 @@ impl FolderDbRepository { let rows = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE id = ANY($1) AND NOT is_trashed "#, @@ -153,7 +168,9 @@ impl FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?; rows.into_iter() - .map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7)) + .map(|r| { + Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7, r.8, r.9, r.10) + }) .collect() } } @@ -163,6 +180,7 @@ impl FolderRepository for FolderDbRepository { &self, name: String, parent_id: Option, + caller_id: Uuid, ) -> Result { // Derive (user_id, drive_id) from parent folder in one round-trip. // Root-level folders require the caller to have set up the home @@ -184,28 +202,34 @@ impl FolderRepository for FolderDbRepository { )); }; - // D0 dual-write: drive_id alongside user_id (drops in D7), plus - // provenance columns created_by/updated_by. The repo derives - // created_by from user_id because the parent's owner is the - // creator on personal drives (the only kind that exists in D0). - // D2 plumbs the real caller_id when shared drives let other - // members write into a drive they don't own. - let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>( + // D0 dual-write: drive_id alongside user_id (drops in D7); plus + // §14 provenance — `created_by` / `updated_by` bind to the caller + // ($5), NOT to the parent folder's `user_id`. Pre-D2 they're + // silently equivalent (only the parent's owner can write); the + // distinction matters once shared drives let an Editor mutate + // a folder owned by someone else. + // + // RETURNING also surfaces the two provenance columns so the + // built entity / DTO carries fresh values without a re-read. + let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option, Option)>( r#" INSERT INTO storage.folders (name, parent_id, user_id, drive_id, created_by, updated_by) - VALUES ($1, $2::uuid, $3, $4, $3, $3) + VALUES ($1, $2::uuid, $3, $4, $5, $5) RETURNING id::text, path, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, + updated_by "#, ) .bind(&name) .bind(&parent_id) .bind(user_id) .bind(drive_id) + .bind(caller_id) .fetch_one(self.pool()) .await .map_err(|e| { @@ -230,6 +254,9 @@ impl FolderRepository for FolderDbRepository { row.2, row.3, row.4, + // Fresh from RETURNING — caller_id was bound to both columns. + row.5, + row.6, ) } @@ -239,7 +266,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE id = $1::uuid AND NOT is_trashed "#, @@ -260,6 +288,8 @@ impl FolderRepository for FolderDbRepository { row.6, row.7, row.8, + row.9, + row.10, ) } @@ -288,7 +318,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE path = $1 AND drive_id = $2 AND NOT is_trashed "#, @@ -310,6 +341,8 @@ impl FolderRepository for FolderDbRepository { row.6, row.7, row.8, + row.9, + row.10, ) } @@ -321,7 +354,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_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 ORDER BY name @@ -336,7 +370,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_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 ORDER BY name @@ -348,8 +383,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) }) .collect() } @@ -366,7 +401,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed ORDER BY name @@ -382,7 +418,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed ORDER BY name @@ -395,8 +432,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) }) .collect() } @@ -419,6 +456,7 @@ impl FolderRepository for FolderDbRepository { EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed @@ -438,6 +476,7 @@ impl FolderRepository for FolderDbRepository { EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed @@ -454,16 +493,18 @@ impl FolderRepository for FolderDbRepository { // total_count is identical in every row; 0 when the result set is empty. let total = if include_total { - Some(rows.first().map_or(0, |r| r.9) as usize) + Some(rows.first().map_or(0, |r| r.11) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) - }) + .map( + |(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) + }, + ) .collect(); Ok((folders?, total)) } @@ -486,6 +527,7 @@ impl FolderRepository for FolderDbRepository { EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed @@ -506,6 +548,7 @@ impl FolderRepository for FolderDbRepository { EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed @@ -522,41 +565,55 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; let total = if include_total { - Some(rows.first().map_or(0, |r| r.9) as usize) + Some(rows.first().map_or(0, |r| r.11) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) - }) + .map( + |(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) + }, + ) .collect(); Ok((folders?, total)) } - async fn rename_folder(&self, id: &str, new_name: String) -> Result { + async fn rename_folder( + &self, + id: &str, + new_name: String, + caller_id: Uuid, + ) -> Result { // The BEFORE UPDATE trigger recomputes path/lpath for this row; // the AFTER UPDATE cascade trigger then batch-updates all // descendants in a single UPDATE using the GiST lpath index. // That multi-row rewrite can deadlock against the tree-ETag // flusher's id-ordered ancestor bump — retry instead of failing // the user's operation (40P01 only; 23505 still maps below). + // + // §14: `updated_by = $3` (caller_id) — the caller mutated this + // row, not the row's owner. In D2 a shared-drive member can + // rename a row they don't own; the previous `updated_by = user_id` + // would have silently recorded the wrong principal. let row = retry_on_deadlock("folders.rename", || { sqlx::query_as::<_, FolderRow>( r#" UPDATE storage.folders - SET name = $1, updated_at = NOW(), updated_by = user_id + SET name = $1, updated_at = NOW(), updated_by = $3 WHERE id = $2::uuid AND NOT is_trashed RETURNING id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by "#, ) .bind(&new_name) .bind(id) + .bind(caller_id) .fetch_optional(self.pool()) }) .await @@ -580,6 +637,8 @@ impl FolderRepository for FolderDbRepository { row.6, row.7, row.8, + row.9, + row.10, ) } @@ -587,25 +646,30 @@ impl FolderRepository for FolderDbRepository { &self, id: &str, new_parent_id: Option<&str>, + caller_id: Uuid, ) -> Result { // The BEFORE UPDATE trigger recomputes path/lpath for this row; // the AFTER UPDATE cascade trigger then batch-updates all // descendants in a single UPDATE using the GiST lpath index. // Retried on deadlock vs the tree-ETag flusher (see rename_folder). + // + // §14: `updated_by = $3` (caller_id), see rename_folder. let row = retry_on_deadlock("folders.move", || { sqlx::query_as::<_, FolderRow>( r#" UPDATE storage.folders - SET parent_id = $1::uuid, updated_at = NOW(), updated_by = user_id + SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3 WHERE id = $2::uuid AND NOT is_trashed RETURNING id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by "#, ) .bind(new_parent_id) .bind(id) + .bind(caller_id) .fetch_optional(self.pool()) }) .await @@ -622,6 +686,8 @@ impl FolderRepository for FolderDbRepository { row.6, row.7, row.8, + row.9, + row.10, ) } @@ -697,7 +763,7 @@ impl FolderRepository for FolderDbRepository { // ── Trash operations ── - async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError> { // Soft-delete the whole subtree in one statement: the root flips // `is_trashed` and records `original_parent_id` so restore knows // where to put it back; every descendant (folder or file) that @@ -712,6 +778,10 @@ impl FolderRepository for FolderDbRepository { // `/g9-tree/file.txt` still resolved 207 even though the parent // collection was gone) — a class of data-integrity drift that // confused desktop-sync tree walks. + // + // §14: all three CTE branches stamp `updated_by = $2` + // (caller_id). The cascade is "the caller trashed this + // subtree", not "each owner trashed their own row". let result = retry_on_deadlock("folders.trash", || { sqlx::query_scalar::<_, i64>( r#" @@ -721,7 +791,7 @@ impl FolderRepository for FolderDbRepository { trashed_at = NOW(), original_parent_id = parent_id, updated_at = NOW(), - updated_by = user_id + updated_by = $2 WHERE id = $1::uuid AND NOT is_trashed RETURNING id, lpath ), @@ -730,7 +800,7 @@ impl FolderRepository for FolderDbRepository { SET is_trashed = TRUE, trashed_at = NOW(), updated_at = NOW(), - updated_by = f.user_id + updated_by = $2 FROM trash_root tr WHERE f.lpath <@ tr.lpath AND f.id != tr.id @@ -742,7 +812,7 @@ impl FolderRepository for FolderDbRepository { SET is_trashed = TRUE, trashed_at = NOW(), updated_at = NOW(), - updated_by = fi.user_id + updated_by = $2 FROM trash_root tr JOIN storage.folders f ON f.lpath <@ tr.lpath WHERE fi.folder_id = f.id @@ -753,6 +823,7 @@ impl FolderRepository for FolderDbRepository { "#, ) .bind(folder_id) + .bind(caller_id) .fetch_one(self.pool()) }) .await @@ -769,6 +840,7 @@ impl FolderRepository for FolderDbRepository { &self, folder_id: &str, _original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError> { // Inverse of the cascade in `move_to_trash`: restore the root // (BEFORE UPDATE trigger recomputes path/lpath via the parent_id @@ -778,6 +850,10 @@ impl FolderRepository for FolderDbRepository { // *before* this folder went to trash have `original_*` set, so // they correctly stay in trash and continue to show up as // top-level trash entries via `storage.trash_items`. + // + // §14: all three CTE branches stamp `updated_by = $2` + // (caller_id). Restoration is "the caller restored this + // subtree", regardless of who originally owned each row. let result = retry_on_deadlock("folders.restore", || { sqlx::query_scalar::<_, i64>( r#" @@ -788,7 +864,7 @@ impl FolderRepository for FolderDbRepository { parent_id = COALESCE(original_parent_id, parent_id), original_parent_id = NULL, updated_at = NOW(), - updated_by = user_id + updated_by = $2 WHERE id = $1::uuid AND is_trashed RETURNING id, lpath ), @@ -797,7 +873,7 @@ impl FolderRepository for FolderDbRepository { SET is_trashed = FALSE, trashed_at = NULL, updated_at = NOW(), - updated_by = f.user_id + updated_by = $2 FROM restore_root rr WHERE f.lpath <@ rr.lpath AND f.id != rr.id @@ -810,7 +886,7 @@ impl FolderRepository for FolderDbRepository { SET is_trashed = FALSE, trashed_at = NULL, updated_at = NOW(), - updated_by = fi.user_id + updated_by = $2 FROM restore_root rr JOIN storage.folders f ON f.lpath <@ rr.lpath WHERE fi.folder_id = f.id @@ -822,6 +898,7 @@ impl FolderRepository for FolderDbRepository { "#, ) .bind(folder_id) + .bind(caller_id) .fetch_one(self.pool()) }) .await @@ -911,16 +988,25 @@ impl FolderRepository for FolderDbRepository { ca, ma, tma, + // INSERT stamped both provenance columns from user_id + // (D0 dual-write); D2 will plumb the real caller_id. + Some(user_id), + Some(user_id), ), None => { - // Already exists — fetch it - let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>( + // Already exists — fetch it. SELECT also pulls the §14 + // provenance columns so the entity layer reflects DB truth. + let existing = sqlx::query_as::< + _, + (String, String, i64, i64, i64, Option, Option), + >( r#" SELECT id::text, path, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE name = $1 AND user_id = $2 AND parent_id IS NULL "#, @@ -940,6 +1026,8 @@ impl FolderRepository for FolderDbRepository { existing.2, existing.3, existing.4, + existing.5, + existing.6, ) } } @@ -955,7 +1043,8 @@ impl FolderRepository for FolderDbRepository { fo.user_id, 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 \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.is_trashed = false \ AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \ @@ -970,8 +1059,8 @@ impl FolderRepository for FolderDbRepository { })?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1018,7 +1107,8 @@ impl FolderRepository for FolderDbRepository { fo.user_id, 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 \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.user_id = $1 \ AND fo.is_trashed = false \ @@ -1042,8 +1132,8 @@ impl FolderRepository for FolderDbRepository { return rows .into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect(); } @@ -1055,7 +1145,8 @@ impl FolderRepository for FolderDbRepository { fo.user_id, 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 \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.parent_id = $1::uuid \ AND fo.user_id = $2 \ @@ -1074,7 +1165,8 @@ impl FolderRepository for FolderDbRepository { fo.user_id, 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 \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.parent_id IS NULL \ AND fo.user_id = $1 \ @@ -1114,8 +1206,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1143,7 +1235,8 @@ impl FolderRepository for FolderDbRepository { fo.user_id, 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 \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.user_id = $1 \ AND fo.is_trashed = false \ @@ -1170,8 +1263,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1192,7 +1285,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_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 @@ -1218,7 +1312,8 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_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 @@ -1241,8 +1336,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) }) .collect() } diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index 831066ff..776b96e0 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -162,6 +162,12 @@ impl PathResolverService { icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not selected by this resolver path — + // it's used for existence/type discrimination, not + // detailed DTO emission. Callers that need provenance + // reload through the repo. + created_by: None, + updated_by: None, })), _ => { let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); @@ -188,6 +194,9 @@ impl PathResolverService { sort_date: None, content_hash: String::new(), etag: String::new(), + // §14 provenance not selected by this resolver path + created_by: None, + updated_by: None, })) } } diff --git a/src/interfaces/api/handlers/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 0998803b..489c2e49 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -417,6 +417,7 @@ impl ChunkedUploadHandler { parts.folder_id.clone(), ingested.content_type.clone(), ingested.stored(), + auth_user.id, ) .await { diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index ada12cd0..08450a96 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -273,6 +273,9 @@ pub async fn list_favorites_resources( icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), + // §14 provenance not selected by the favorites query. + created_by: None, + updated_by: None, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -316,6 +319,9 @@ pub async fn list_favorites_resources( sort_date: None, content_hash, etag, + // §14 provenance not selected by the favorites query. + created_by: None, + updated_by: None, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 76296d4c..822d3303 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -284,6 +284,7 @@ impl FileHandler { folder_id, ingested.content_type.clone(), ingested.stored(), + auth_user.id, ) .await { diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 048e285c..8403f75e 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -711,6 +711,9 @@ pub async fn list_folder_resources( icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not selected by the resources query. + created_by: None, + updated_by: None, }; FolderResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -753,6 +756,9 @@ pub async fn list_folder_resources( sort_date: None, content_hash, etag, + // §14 provenance not selected by the resources query. + created_by: None, + updated_by: None, }; FolderResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 7d11fb19..eb313b40 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -303,6 +303,9 @@ pub async fn list_recent_resources( icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), + // §14 provenance not selected by the recents query. + created_by: None, + updated_by: None, }; RecentResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -344,6 +347,9 @@ pub async fn list_recent_resources( sort_date: None, content_hash, etag, + // §14 provenance not selected by the recents query. + created_by: None, + updated_by: None, }; RecentResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index dc938d35..74b1bde0 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -438,6 +438,9 @@ async fn handle_propfind( icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not applicable to the synthetic root. + created_by: None, + updated_by: None, }; return build_streaming_propfind_response( @@ -1197,7 +1200,14 @@ async fn handle_put( let content_type = ingested.content_type.clone(); let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let result = file_upload_service - .update_file_streaming(&path, drive_id, ingested.stored(), &content_type, None) + .update_file_streaming( + &path, + drive_id, + ingested.stored(), + &content_type, + None, + user.id, + ) .await; match result { diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 90134631..232038a5 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -258,7 +258,14 @@ async fn put_file( .app_state .applications .file_upload_service - .update_file_streaming(&file.path, drive_id, ingested.stored(), &content_type, None) + .update_file_streaming( + &file.path, + drive_id, + ingested.stored(), + &content_type, + None, + claims_sub_uuid, + ) .await; match result { diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 57fd868b..01d37b05 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -344,6 +344,9 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes sort_date: None, content_hash: fr.blob_hash.clone(), etag, + // §14 provenance not selected by the search result DTO. + created_by: None, + updated_by: None, } } @@ -368,6 +371,9 @@ fn folder_dto_from_search( icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not selected by search results. + created_by: None, + updated_by: None, } } diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index ddb18fda..e96fe674 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -298,6 +298,7 @@ async fn handle_assemble( ingested.stored(), &content_type, oc_mtime, + user.id, ) .await .map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?; @@ -334,6 +335,7 @@ async fn handle_assemble( Some(parent_folder.id), content_type.to_string(), ingested.stored(), + user.id, ) .await .map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?; diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 0069558f..05e3f060 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -754,6 +754,7 @@ async fn handle_put( ingested.stored(), &content_type, oc_mtime, + session.user.id, ) .await .map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?; @@ -1667,6 +1668,9 @@ mod tests { icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), etag: String::new(), + // §14 provenance not relevant to path-mapper tests. + created_by: None, + updated_by: None, } } diff --git a/tests/api/files-folders.hurl b/tests/api/files-folders.hurl index 02155acb..43c2865b 100644 --- a/tests/api/files-folders.hurl +++ b/tests/api/files-folders.hurl @@ -19,9 +19,11 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" +jsonpath "$.user.id" isString # ───────────────────────────────────────────────────────────── @@ -102,6 +104,9 @@ test1_id: jsonpath "$.id" jsonpath "$.id" isString jsonpath "$.name" == "test1" jsonpath "$.parent_id" == {{home_folder_id}} +# D0 §14 provenance — self-creation: both fields stamp the caller. +jsonpath "$.created_by" == "{{admin_user_id}}" +jsonpath "$.updated_by" == "{{admin_user_id}}" # ───────────────────────────────────────────────────────────── @@ -169,6 +174,9 @@ jsonpath "$.name" == "hello.txt" jsonpath "$.folder_id" == {{test2_id}} jsonpath "$.size" == 32 jsonpath "$.mime_type" == "text/plain" +# D0 §14 provenance — uploader's id stamps both fields on a fresh upload. +jsonpath "$.created_by" == "{{admin_user_id}}" +jsonpath "$.updated_by" == "{{admin_user_id}}" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index e3cb3e6a..588ebc67 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -13,6 +13,8 @@ # ───────────────────────────────────────────────────────────── # Step 1 — Login as admin (Alice), capture token + home folder. +# `alice_user_id` is captured for the D0 §14 provenance assertions +# that compare `created_by` / `updated_by` on resources Alice owns. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/login Content-Type: application/json @@ -21,6 +23,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -85,6 +88,10 @@ Content-Type: application/json HTTP 201 [Captures] shared_folder_id: jsonpath "$.id" +[Asserts] +# D0 §14 provenance — Alice creates, so both fields stamp Alice. +jsonpath "$.created_by" == "{{alice_user_id}}" +jsonpath "$.updated_by" == "{{alice_user_id}}" POST {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -667,6 +674,12 @@ Content-Type: application/json { "name": "renamed-by-adam-as-editor" } HTTP 200 +[Asserts] +# D0 §14 provenance — folder counterpart of the file rename below. +# Adam (Editor) mutates Alice's folder; `updated_by` becomes Adam, +# `created_by` stays Alice. +jsonpath "$.created_by" == "{{alice_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" PUT {{base_url}}/api/files/{{perm_file_id}}/rename Authorization: Bearer {{adam_token}} @@ -674,6 +687,16 @@ Content-Type: application/json { "name": "adam-renamed-logo.jpg" } HTTP 200 +[Asserts] +# D0 §14 provenance — Adam (an Editor, not the owner) mutates the +# file, so `updated_by` switches to Adam's id while `created_by` +# stays Alice (the original uploader). This is the canonical +# cross-user provenance check: distinguishes "who first put this +# here" from "who last touched it" and proves the mutator's id +# overrides the row's `user_id` (pre-D0 they were silently the +# same; post-D0 they can diverge once a non-owner mutates). +jsonpath "$.created_by" == "{{alice_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" # ── Thumbnail push (Update) succeeds ──────────────────────── PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview @@ -690,6 +713,15 @@ Content-Type: application/json { "name": "adam-created-child", "parent_id": "{{perm_folder_id}}" } HTTP 201 +[Asserts] +# D0 §14 provenance — Adam (Editor on Alice's folder) creates a +# child folder inside it. Both `created_by` and `updated_by` stamp +# Adam: he's the original author AND the last toucher of this +# fresh row. The parent's owner (Alice) doesn't appear anywhere on +# the new row's provenance — content authored in a shared scope +# belongs to its author. +jsonpath "$.created_by" == "{{adam_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" POST {{base_url}}/api/files/upload Authorization: Bearer {{adam_token}} @@ -698,6 +730,10 @@ folder_id: {{perm_folder_id}} file: file,fixtures/hello.txt; text/plain HTTP 201 +[Asserts] +# Same shape for a file upload: Adam authored, Adam touched last. +jsonpath "$.created_by" == "{{adam_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" # ── Chunked upload full lifecycle as Editor ───────────────── # 1. Open session (server pre-checks Create on folder)