diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 408746c..3d75bc0 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -143,6 +143,29 @@ impl Server { } } } + // inbuxa: AL-7: a delegate reaches the whole locked account, + // mail, calendars, contacts and files, even a kind it holds + // none of yet, so an empty one reads as empty rather than + // refused. What it may see or change there is still each + // container's grant. + for delegation in delegations.iter() { + let whole: Bitmap = Bitmap::from_iter([ + Collection::Mailbox, + Collection::Email, + Collection::Calendar, + Collection::CalendarEvent, + Collection::AddressBook, + Collection::ContactCard, + Collection::FileNode, + ]); + match access_to.iter_mut().find(|a| a.account_id == delegation.account_id) { + Some(entry) => entry.collections.union(&whole), + None => access_to.push(AccessTo { + account_id: delegation.account_id, + collections: whole, + }), + } + } let now = now(); let mut credential_version = 0; @@ -817,6 +840,13 @@ impl AccessToken { /// inbuxa: AL-5: this account's delegation into a locked account, if it /// has one that hasn't ended. + /// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to + /// the locked account as its owner could, top-level folders included. + pub fn delegate_may_write(&self, account_id: u32) -> bool { + self.delegation(account_id) + .is_some_and(|d| d.access != inbuxa_features::lock::Access::Read) + } + pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> { let now = now(); self.inner diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index 6cdddfd..d0e56db 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -226,9 +226,14 @@ impl FileNodeCopy for Server { } }; - if let Err(err) = - validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders) - { + // inbuxa: AL-7: a writing delegate may add at the top + if let Err(err) = validate_file_node_hierarchy( + None, + &file_node, + is_shared && !access_token.delegate_may_write(account_id), + &cache, + &created_folders, + ) { response.not_created.append(id, err); continue 'create; } @@ -362,7 +367,7 @@ impl FileNodeCopy for Server { ); continue 'create; } - } else if is_shared { + } else if is_shared && !access_token.delegate_may_write(account_id) { response.not_created.append( id, SetError::forbidden() diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index 2317df9..428eb55 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -149,9 +149,15 @@ impl FileNodeSet for Server { }; // Validate hierarchy - if let Err(err) = - validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders) - { + // inbuxa: AL-7: a writing delegate may add at the top of a + // locked account, which may hold no folders at all + if let Err(err) = validate_file_node_hierarchy( + None, + &file_node, + is_shared && !access_token.delegate_may_write(account_id), + &cache, + &created_folders, + ) { response.not_created.append(id, err); continue 'create; } diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs index 40067d0..9117751 100644 --- a/tests/src/system/account_lock.rs +++ b/tests/src/system/account_lock.rs @@ -36,6 +36,9 @@ const USING: &[&str] = &[ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission", + "urn:ietf:params:jmap:calendars", + "urn:ietf:params:jmap:contacts", + "urn:ietf:params:jmap:filenode", "urn:inbuxa:jmap", ]; @@ -179,6 +182,26 @@ pub async fn test(test: &mut TestServer) { assert_eq!(delegation["access"], "read", "AL-7"); assert_eq!(delegation["sendAs"], false, "AL-7"); + // AL-7: the whole account, not only mail: even a kind the owner holds + // none of (no files here) reads as empty rather than refused + for (method, arguments) in [ + ("FileNode/query", json!({"accountId": owner_id})), + ("Calendar/get", json!({"accountId": owner_id, "ids": null})), + ("AddressBook/get", json!({"accountId": owner_id, "ids": null})), + ] { + let (name, response) = delegate.call(method, arguments).await; + assert_eq!(name, method, "AL-7: {method} refused to the delegate: {response}"); + } + + // AL-6: reading adds nothing, not even at the top of empty Files + let (_, response) = delegate + .call( + "FileNode/set", + json!({"accountId": owner_id, "create": {"f": {"name": "Notes", "parentId": null}}}), + ) + .await; + assert!(response["created"].get("f").is_none(), "AL-6: a read delegate added a file: {response}"); + // The delegate reads the mail that arrived let (_, found) = delegate .call( @@ -222,6 +245,27 @@ pub async fn test(test: &mut TestServer) { "AL-5: {response}" ); + // AL-7: organize adds at the top of the locked account's Files, which + // held none, and sees what it made + let (_, response) = delegate + .call( + "FileNode/set", + json!({"accountId": owner_id, "create": {"f": {"name": "Handover notes", "parentId": null}}}), + ) + .await; + let folder_id = response["created"]["f"]["id"] + .as_str() + .unwrap_or_else(|| panic!("AL-7: organize couldn't add to empty Files: {response}")) + .to_string(); + let (_, response) = delegate + .call("FileNode/get", json!({"accountId": owner_id, "ids": [folder_id]})) + .await; + assert_eq!( + response["list"].as_array().map(Vec::len), + Some(1), + "AL-7: the delegate can't see the folder it made: {response}" + ); + // Test 12, AL-6, AL-7: organize makes folders it can see, moves mail, // never deletes it let (_, mailboxes) = delegate