diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 2e1f94eb..907195a4 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -608,6 +608,25 @@ impl AccessToken { self.inner.account_id == account_id || self.inner.member_of.contains(&account_id) } + /// inbuxa: #186: an account that shared something with this token and + /// isn't its own or a group's it belongs to. Reads there go through the + /// share even for an `Impersonate` holder, so an administrator sees what + /// was shared with them, with the share's rights, like anyone else. + /// Writes still follow `is_member`. + pub fn reads_through_share(&self, account_id: u32) -> bool { + !self.is_member_directly(account_id) + && self + .inner + .access_to + .iter() + .any(|a| a.account_id == account_id) + } + + /// inbuxa: #186: reads see the whole account (see `reads_through_share`). + pub fn reads_as_owner(&self, account_id: u32) -> bool { + self.is_member(account_id) && !self.reads_through_share(account_id) + } + /// inbuxa: MA-D0: in the account only because it is a group this token /// belongs to. Such a member has the group's mailbox but may not share it /// on: who is in a group is an administrator's decision, and a share diff --git a/crates/common/src/sharing/mod.rs b/crates/common/src/sharing/mod.rs index 5f14e145..2782e884 100644 --- a/crates/common/src/sharing/mod.rs +++ b/crates/common/src/sharing/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::auth::AccessToken; @@ -27,7 +29,7 @@ impl EffectiveAcl for &[AclGrant] { fn effective_acl(&self, access_token: &AccessToken) -> Bitmap { let mut acl = Bitmap::::new(); for item in self.iter() { - if access_token.is_member(item.account_id) { + if access_token.is_member_directly(item.account_id) { acl.union(&item.grants); } } @@ -40,7 +42,7 @@ impl EffectiveAcl for ArchivedVec { fn effective_acl(&self, access_token: &AccessToken) -> Bitmap { let mut acl = Bitmap::::new(); for item in self.iter() { - if access_token.is_member(item.account_id.into()) { + if access_token.is_member_directly(item.account_id.into()) { acl.union_raw(item.grants.bitmap); } } diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index c12fc56c..74ee8fee 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{DavResources, auth::AccessToken}; @@ -22,7 +24,7 @@ impl DavResources { for resource in &self.resources { if let Some(acls) = resource.acls() { for acl in acls { - if access_token.is_member(acl.account_id) { + if access_token.is_member_directly(acl.account_id) { let mut grants = acl.grants; grants.intersection(&check_acls); if grants == check_acls || (match_any && !grants.is_empty()) { @@ -102,7 +104,7 @@ impl DavResources { && let Some(acls) = resource.acls() { for acl in acls { - if access_token.is_member(acl.account_id) { + if access_token.is_member_directly(acl.account_id) { let mut grants = acl.grants; grants.intersection(&check_acls); return !grants.is_empty(); @@ -123,7 +125,7 @@ impl DavResources { && let Some(acls) = resource.acls() { for acl in acls { - if access_token.is_member(acl.account_id) { + if access_token.is_member_directly(acl.account_id) { account_acls.union(&acl.grants); } } diff --git a/crates/jmap/src/addressbook/get.rs b/crates/jmap/src/addressbook/get.rs index f078f48e..3ea56215 100644 --- a/crates/jmap/src/addressbook/get.rs +++ b/crates/jmap/src/addressbook/get.rs @@ -60,7 +60,7 @@ impl AddressBookGet for Server { SyncCollection::AddressBook, ) .await?; - let address_book_ids = if access_token.is_member(account_id) { + let address_book_ids = if access_token.reads_as_owner(account_id) { cache.document_ids(true).collect::() } else { cache.shared_containers(access_token, [Acl::Read, Acl::ReadItems], true) @@ -177,7 +177,7 @@ impl AddressBookGet for Server { AddressBookProperty::MyRights => { result.insert_unchecked( AddressBookProperty::MyRights, - if access_token.is_shared(account_id) { + if access_token.reads_through_share(account_id) { JmapRights::rights::( address_book.acls.effective_acl(access_token), ) diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index b7c0b63a..b5648b99 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -116,7 +116,7 @@ impl SessionHandler for Server { // Add secondary accounts for &account_id in access_token.secondary_ids() { - let is_owner = access_token.is_member(account_id); + let is_owner = access_token.reads_as_owner(account_id); let Some(account) = self .try_account(account_id) .await diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index ae8e8221..a88882a1 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -116,7 +116,7 @@ impl BlobDownload for Server { collection, document_id, } => { - if access_token.is_member(*account_id) { + if access_token.reads_as_owner(*account_id) { // inbuxa: AU-1.6: another account's blob self.audit_foreign_access(access_token, *account_id, true) .await; diff --git a/crates/jmap/src/calendar/get.rs b/crates/jmap/src/calendar/get.rs index 868dd4df..b755ceaf 100644 --- a/crates/jmap/src/calendar/get.rs +++ b/crates/jmap/src/calendar/get.rs @@ -73,7 +73,7 @@ impl CalendarGet for Server { SyncCollection::Calendar, ) .await?; - let is_owner = access_token.is_member(account_id); + let is_owner = access_token.reads_as_owner(account_id); let calendar_ids = if is_owner { cache.document_ids(true).collect::() } else { @@ -250,7 +250,7 @@ impl CalendarGet for Server { CalendarProperty::MyRights => { result.insert_unchecked( CalendarProperty::MyRights, - if access_token.is_shared(account_id) { + if access_token.reads_through_share(account_id) { JmapRights::rights::( calendar.acls.effective_acl(access_token), ) diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index 0502c796..a5932ae4 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -90,7 +92,7 @@ impl JmapCalendarEventCopy for Server { ) .await .caused_by(trc::location!())?; - let from_calendar_event_ids = if access_token.is_member(from_account_id) { + let from_calendar_event_ids = if access_token.reads_as_owner(from_account_id) { from_cache.document_ids(false).collect::() } else { from_cache.shared_items(access_token, [Acl::ReadItems], true) diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index ae2cdabb..f866331f 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{calendar_event::CalendarSyntheticId, changes::state::JmapCacheState}; @@ -71,7 +73,7 @@ impl CalendarEventGet for Server { SyncCollection::Calendar, ) .await?; - let calendar_event_ids = if access_token.is_member(account_id) { + let calendar_event_ids = if access_token.reads_as_owner(account_id) { cache.document_ids(false).collect::() } else { cache.shared_items(access_token, [Acl::ReadItems], true) diff --git a/crates/jmap/src/calendar_event/query.rs b/crates/jmap/src/calendar_event/query.rs index 8de01ddb..fc19452e 100644 --- a/crates/jmap/src/calendar_event/query.rs +++ b/crates/jmap/src/calendar_event/query.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; @@ -248,7 +250,7 @@ impl CalendarEventQuery for Server { .with_filters(filters) .with_comparators(comparators) .with_account_id(account_id) - .with_mask(if access_token.is_shared(account_id) { + .with_mask(if access_token.reads_through_share(account_id) { cache.shared_items(access_token, [Acl::ReadItems], true) } else { cache.document_ids(false).collect() diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 398d7866..4776b842 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -134,7 +134,7 @@ impl ChangesLookup for Server { }; let account_id = request.account_id.document_id(); - let allowed_ids: Option = if access_token.is_member(account_id) { + let allowed_ids: Option = if access_token.reads_as_owner(account_id) { None } else { Some(match object { diff --git a/crates/jmap/src/contact/copy.rs b/crates/jmap/src/contact/copy.rs index a6e68b41..f5d78b2b 100644 --- a/crates/jmap/src/contact/copy.rs +++ b/crates/jmap/src/contact/copy.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{changes::state::JmapCacheState, contact::set::ContactCardSet}; @@ -87,7 +89,7 @@ impl JmapContactCardCopy for Server { ) .await .caused_by(trc::location!())?; - let from_contact_ids = if access_token.is_member(from_account_id) { + let from_contact_ids = if access_token.reads_as_owner(from_account_id) { from_cache.document_ids(false).collect::() } else { from_cache.shared_items(access_token, [Acl::ReadItems], true) diff --git a/crates/jmap/src/contact/get.rs b/crates/jmap/src/contact/get.rs index c0baf257..bc6d37e3 100644 --- a/crates/jmap/src/contact/get.rs +++ b/crates/jmap/src/contact/get.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::changes::state::JmapCacheState; @@ -52,7 +54,7 @@ impl ContactCardGet for Server { SyncCollection::AddressBook, ) .await?; - let contact_ids = if access_token.is_member(account_id) { + let contact_ids = if access_token.reads_as_owner(account_id) { cache.document_ids(false).collect::() } else { cache.shared_items(access_token, [Acl::ReadItems], true) diff --git a/crates/jmap/src/contact/query.rs b/crates/jmap/src/contact/query.rs index 840629a5..c219db20 100644 --- a/crates/jmap/src/contact/query.rs +++ b/crates/jmap/src/contact/query.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; @@ -313,7 +315,7 @@ impl ContactCardQuery for Server { .with_filters(filters) .with_comparators(comparators) .with_account_id(account_id) - .with_mask(if access_token.is_shared(account_id) { + .with_mask(if access_token.reads_through_share(account_id) { cache.shared_items(access_token, [Acl::ReadItems], true) } else { cache.document_ids(false).collect() diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index 96b98ff6..2db0128b 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -79,7 +81,7 @@ impl JmapEmailCopy for Server { .get_cached_messages(from_account_id) .await .caused_by(trc::location!())?; - let from_message_ids = if access_token.is_member(from_account_id) { + let from_message_ids = if access_token.reads_as_owner(from_account_id) { from_cache.email_document_ids() } else { from_cache.shared_messages(access_token, Acl::ReadItems) diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs index 2a1e1e41..5554c6e1 100644 --- a/crates/jmap/src/email/get.rs +++ b/crates/jmap/src/email/get.rs @@ -112,7 +112,7 @@ impl EmailGet for Server { .get_cached_messages(account_id) .await .caused_by(trc::location!())?; - let message_ids = if access_token.is_member(account_id) { + let message_ids = if access_token.reads_as_owner(account_id) { cache.email_document_ids() } else { cache.shared_messages(access_token, Acl::ReadItems) diff --git a/crates/jmap/src/email/query.rs b/crates/jmap/src/email/query.rs index fb200ed4..4bdc8f51 100644 --- a/crates/jmap/src/email/query.rs +++ b/crates/jmap/src/email/query.rs @@ -356,7 +356,7 @@ impl EmailQuery for Server { .with_filters(filters) .with_comparators(comparators) .with_account_id(account_id) - .with_mask(if access_token.is_shared(account_id) { + .with_mask(if access_token.reads_through_share(account_id) { cached_messages.shared_messages(access_token, Acl::ReadItems) } else { cached_messages diff --git a/crates/jmap/src/email/snippet.rs b/crates/jmap/src/email/snippet.rs index 0127abe7..b8ebcd74 100644 --- a/crates/jmap/src/email/snippet.rs +++ b/crates/jmap/src/email/snippet.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::{Server, auth::AccessToken}; @@ -98,7 +100,7 @@ impl EmailSearchSnippet for Server { .get_cached_messages(account_id) .await .caused_by(trc::location!())?; - let document_ids = if access_token.is_member(account_id) { + let document_ids = if access_token.reads_as_owner(account_id) { cached_messages.email_document_ids() } else { cached_messages.shared_messages(access_token, Acl::ReadItems) diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index d0e56db5..27b37ff5 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -98,7 +98,7 @@ impl FileNodeCopy for Server { ) .await .caused_by(trc::location!())?; - let from_node_ids = if access_token.is_member(from_account_id) { + let from_node_ids = if access_token.reads_as_owner(from_account_id) { from_cache .resources .iter() diff --git a/crates/jmap/src/file/get.rs b/crates/jmap/src/file/get.rs index 6f3c0e35..8ba5d7c9 100644 --- a/crates/jmap/src/file/get.rs +++ b/crates/jmap/src/file/get.rs @@ -71,7 +71,7 @@ impl FileNodeGet for Server { ) .await?; // TODO: draft-14 section 5 case 2 - ancestors of shared nodes should be discoverable with mayRead=false - let file_node_ids = if access_token.is_member(account_id) { + let file_node_ids = if access_token.reads_as_owner(account_id) { cache .resources .iter() @@ -169,7 +169,7 @@ impl FileNodeGet for Server { FileNodeProperty::MyRights => { result.insert_unchecked( FileNodeProperty::MyRights, - if access_token.is_shared(account_id) { + if access_token.reads_through_share(account_id) { JmapRights::rights::( file_node.acls.effective_acl(access_token), ) diff --git a/crates/jmap/src/file/query.rs b/crates/jmap/src/file/query.rs index bff5cc15..f0b66e60 100644 --- a/crates/jmap/src/file/query.rs +++ b/crates/jmap/src/file/query.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; @@ -193,7 +195,7 @@ impl FileNodeQuery for Server { let results = SearchQuery::new(SearchIndex::InMemory) .with_filters(filters) - .with_mask(if access_token.is_shared(account_id) { + .with_mask(if access_token.reads_through_share(account_id) { cache.shared_documents(access_token, [Acl::Read, Acl::ReadItems], true) } else { cache.resources.iter().map(|r| r.document_id).collect() diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index cf424810..079161da 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/get.rs @@ -51,7 +51,7 @@ impl MailboxGet for Server { let account_id = request.account_id.document_id(); let personal_id = access_token.personal_id(account_id, Collection::Mailbox); let cache = self.get_cached_messages(account_id).await?; - let shared_ids = if access_token.is_shared(account_id) { + let shared_ids = if access_token.reads_through_share(account_id) { cache.shared_mailboxes(access_token, Acl::Read).into() } else { None @@ -137,7 +137,7 @@ impl MailboxGet for Server { .into(), ), MailboxProperty::MyRights => { - if access_token.is_shared(account_id) { + if access_token.reads_through_share(account_id) { JmapRights::rights::( cached_mailbox.acls.as_slice().effective_acl(access_token), ) diff --git a/crates/jmap/src/mailbox/query.rs b/crates/jmap/src/mailbox/query.rs index ef917b5b..b81a159d 100644 --- a/crates/jmap/src/mailbox/query.rs +++ b/crates/jmap/src/mailbox/query.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; @@ -229,7 +231,7 @@ impl MailboxQuery for Server { let mut results = SearchQuery::new(SearchIndex::InMemory) .with_filters(filters) .with_comparators(comparators) - .with_mask(if access_token.is_shared(account_id) { + .with_mask(if access_token.reads_through_share(account_id) { mailboxes.shared_mailboxes(access_token, Acl::Read) } else { mailboxes diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs index e24010f2..31771347 100644 --- a/crates/jmap/src/principal/availability.rs +++ b/crates/jmap/src/principal/availability.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{calendar::Availability, calendar_event::CalendarSyntheticId}; @@ -118,7 +120,7 @@ impl PrincipalGetAvailability for Server { // Obtain shared ids let is_account_owner = principal_id == account_id; - let shared_ids = if !access_token.is_member(account_id) { + let shared_ids = if !access_token.reads_as_owner(account_id) { // Condition: The user has the "mayReadFreeBusy" permission for the calendar. let shared_ids = resources.shared_items( access_token, diff --git a/crates/jmap/src/thread/get.rs b/crates/jmap/src/thread/get.rs index 995d0b16..51843a57 100644 --- a/crates/jmap/src/thread/get.rs +++ b/crates/jmap/src/thread/get.rs @@ -44,7 +44,7 @@ impl ThreadGet for Server { .get_cached_messages(account_id) .await .caused_by(trc::location!())?; - let shared_ids = if access_token.is_shared(account_id) { + let shared_ids = if access_token.reads_through_share(account_id) { Some(cache.shared_messages(access_token, Acl::ReadItems)) } else { None diff --git a/tests/src/system/audit.rs b/tests/src/system/audit.rs index ed17d2c5..780bba28 100644 --- a/tests/src/system/audit.rs +++ b/tests/src/system/audit.rs @@ -253,7 +253,7 @@ pub async fn test(test: &mut TestServer) { &mail, json!([["Mailbox/set", { "accountId": sharer.id_string(), - "update": {inbox: {format!("shareWith/{admin_id}"): {"mayReadItems": true}}} + "update": {inbox.clone(): {format!("shareWith/{admin_id}"): {"mayReadItems": true}}} }, "0"]]), ) .await; @@ -281,6 +281,50 @@ pub async fn test(test: &mut TestServer) { "test 3b" ); + // Test 3c (#186): in an account that shared with them, an administrator + // reads what was shared, with the share's rights: not every folder, and + // not folders shared with someone else. + let sent = mailboxes + .0 + .pointer("/methodResponses/0/1/list") + .and_then(|list| list.as_array()) + .and_then(|list| list.iter().find(|m| m["role"] == "sent")) + .and_then(|m| m["id"].as_str()) + .unwrap_or_else(|| panic!("test 3c: no Sent in {}", mailboxes.0)) + .to_string(); + let shared = sharer + .jmap_request( + &mail, + json!([["Mailbox/set", { + "accountId": sharer.id_string(), + "update": {sent: {format!("shareWith/{}", user.id_string()): {"mayReadItems": true}}} + }, "0"]]), + ) + .await; + assert!( + shared + .0 + .pointer("/methodResponses/0/1/updated") + .is_some_and(|updated| !updated.is_null()), + "test 3c: share: {}", + shared.0 + ); + let response = admin + .jmap_request( + &mail, + json!([["Mailbox/get", {"accountId": sharer.id_string(), "ids": null, "properties": ["id", "myRights"]}, "0"]]), + ) + .await; + let list = response + .0 + .pointer("/methodResponses/0/1/list") + .and_then(|list| list.as_array()) + .unwrap_or_else(|| panic!("test 3c: {}", response.0)); + assert_eq!(list.len(), 1, "test 3c: {}", response.0); + assert_eq!(list[0]["id"], inbox.as_str(), "test 3c"); + assert_eq!(list[0]["myRights"]["mayReadItems"], true, "test 3c"); + assert_eq!(list[0]["myRights"]["mayDelete"], false, "test 3c"); + // AU-9: a plain user can't read the audit log let plain = Account::new( "audituser@example.org",