From d4d127fa7deca03786ab346acd50bf7340c32bb5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 17:58:50 -0700 Subject: [PATCH 1/2] Delegates reach the whole locked account A delegate's token listed the locked account only for kinds of data it held grants on, so one with no files (or no calendar) was refused to the delegate outright: "You do not have access to account". The token now lists the locked account for mail, calendars, contacts and files alike, so an empty kind reads as empty. What the delegate may see or change is still each container's grant (AL-7). --- crates/common/src/auth/access_token.rs | 23 +++++++++++++++++++++++ tests/src/system/account_lock.rs | 14 ++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 408746c..95c82b4 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; diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs index 40067d0..9b37538 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,17 @@ 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}"); + } + // The delegate reads the mail that arrived let (_, found) = delegate .call( -- 2.54.0 From 9f6761c9ddec5ccb9ef0c047bb94dcccb294523f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:04:24 -0700 Subject: [PATCH 2/2] Writing delegates may add at the top of a locked account's Files A shared account refuses top-level folders, so an organize or full delegate couldn't add anything to a locked account with no folders. A delegate who may write now can, as the owner could; the reconcile after the create grants it the new folder. Read delegates still can't (AL-6, AL-7). --- crates/common/src/auth/access_token.rs | 7 ++++++ crates/jmap/src/file/copy.rs | 13 +++++++---- crates/jmap/src/file/set.rs | 12 ++++++++--- tests/src/system/account_lock.rs | 30 ++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 95c82b4..3d75bc0 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -840,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 9b37538..9117751 100644 --- a/tests/src/system/account_lock.rs +++ b/tests/src/system/account_lock.rs @@ -193,6 +193,15 @@ pub async fn test(test: &mut TestServer) { 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( @@ -236,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 -- 2.54.0