diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 3d75bc0..da38492 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -553,6 +553,16 @@ impl AccessToken { || self.inner.access_to.iter().any(|a| a.account_id == 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 + /// would let anyone in. + pub fn is_group_member_only(&self, account_id: u32) -> bool { + self.inner.account_id != account_id + && self.inner.member_of.contains(&account_id) + && !self.has_permission(Permission::Impersonate) + } + pub fn is_account_id(&self, account_id: u32) -> bool { self.inner.account_id == account_id } diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index 56d3824..b738ad2 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -212,7 +212,7 @@ impl Session { } rights } else { - vec![ + let mut rights = vec![ Rights::Read, Rights::Lookup, Rights::Insert, @@ -223,8 +223,12 @@ impl Session { Rights::CreateMailbox, Rights::DeleteMailbox, Rights::Post, - Rights::Administer, - ] + ]; + // inbuxa: MA-D0: a group's members don't share its mailboxes on. + if !access_token.is_group_member_only(mailbox_id.account_id) { + rights.push(Rights::Administer); + } + rights }; trc::event!( @@ -266,10 +270,20 @@ impl Session { spawn_op!(data, { // Validate mailbox - let (mailbox_id, current_mailbox, _) = data + let (mailbox_id, current_mailbox, access_token) = data .get_acl_mailbox(&arguments, true) .await .imap_ctx(&arguments.tag, trc::location!())?; + + // inbuxa: MA-D0: a group's members don't share its mailboxes on. + if access_token.is_group_member_only(mailbox_id.account_id) { + return Err(trc::ImapEvent::Error + .into_err() + .details("This mailbox belongs to a group. Only an administrator can change who has it.") + .code(ResponseCode::NoPerm) + .id(arguments.tag.to_string())); + } + let current_mailbox = current_mailbox .into_deserialized::() .imap_ctx(&arguments.tag, trc::location!())?; diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index c5e4062..cf42481 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/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 common::{Server, auth::AccessToken, sharing::EffectiveAcl}; @@ -14,6 +16,7 @@ use jmap_tools::{Map, Value}; use std::future::Future; use store::ahash::AHashSet; use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse}; +use utils::map::bitmap::Bitmap; use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; @@ -138,6 +141,11 @@ impl MailboxGet for Server { JmapRights::rights::( cached_mailbox.acls.as_slice().effective_acl(access_token), ) + } else if access_token.is_group_member_only(account_id) { + // inbuxa: MA-D0: everything but sharing it on. + let mut acl = Bitmap::::all(); + acl.remove(Acl::Share); + JmapRights::rights::(acl) } else { JmapRights::all_rights::() } diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 14bc642..94b92fe 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -613,6 +613,15 @@ impl MailboxSet for Server { // Refresh ACLs let current = update.map(|(_, current)| current); if has_acl_changes { + // inbuxa: MA-D0: a group's members don't share its mailboxes on. + if ctx.access_token.is_group_member_only(ctx.account_id) { + return Ok(Err(SetError::forbidden() + .with_property(MailboxProperty::ShareWith) + .with_description( + "This mailbox belongs to a group. Only an administrator can change who has it.", + ))); + } + if !changes.acls.is_empty() && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await { diff --git a/tests/src/jmap/mail/acl.rs b/tests/src/jmap/mail/acl.rs index 0ab4b4c..23a607f 100644 --- a/tests/src/jmap/mail/acl.rs +++ b/tests/src/jmap/mail/acl.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::utils::server::TestServer; @@ -713,6 +715,35 @@ pub async fn test(test: &TestServer) { .await, ); + // inbuxa: MA-D0: a member can't share the group's mailbox on, and isn't + // told it may. Who is in a group is an administrator's decision. + assert_forbidden( + john_client + .set_default_account_id(sales.id_string()) + .mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems]) + .await, + ); + assert!( + !john_client + .set_default_account_id(sales.id_string()) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await + .unwrap() + .unwrap() + .my_rights() + .unwrap() + .acl_list() + .contains(&ACL::Administer) + ); + bill_client.refresh_session().await.unwrap(); + assert!(bill_client.session().account(sales.id_string()).is_none()); + assert_forbidden( + bill_client + .set_default_account_id(sales.id_string()) + .email_get(&email_id, [Property::Subject].into()) + .await, + ); + // Remove John from the sales group admin .registry_update_object(