diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 9b7fe48..0d53b52 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem}; use xxhash_rust::xxh3; impl Server { + /// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail + /// (the server's switch, narrowed by the tenant's). + pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result { + let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant); + Ok( + inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id) + .await + .caused_by(trc::location!())? + .mail_sharing, + ) + } + + /// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked + /// account's or shared mailbox's grants are an administrator's and always + /// do; anyone else's only while their tenant allows mail sharing. + pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result { + if inbuxa_features::lock::get(self.store(), owner) + .await + .caused_by(trc::location!())? + .is_some() + { + return Ok(true); + } + self.mail_sharing_allowed(owner).await + } + async fn build_access_token( &self, account: Account, @@ -100,6 +126,9 @@ impl Server { .map(|m| m.id() as u32) .collect::>(); let mut access_to: Vec = Vec::new(); + // inbuxa: MA-C: whether an owner's mail shares are honored, + // looked up once per owner + let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new(); for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) { for acl_item in self .store() @@ -120,6 +149,27 @@ impl Server { .caused_by(trc::location!())); } + // inbuxa: MA-C: a mail share from an account whose + // tenant (or server) has mail sharing off gives + // nothing while it is off. It stays stored, so it + // comes back when sharing does. A lock's and a + // shared mailbox's grants are an administrator's, + // and always count. + if collection == Collection::Mailbox { + let owner = acl_item.to_account_id; + let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) { + Some((_, honored)) => *honored, + None => { + let honored = self.mail_shares_honored(owner).await?; + mail_shares_honored.push((owner, honored)); + honored + } + }; + if !honored { + continue; + } + } + let mut collections: Bitmap = Bitmap::new(); if acl.contains(Acl::Read) { collections.insert(collection); diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index 7bf8b9b..7ad5f33 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -15,4 +15,5 @@ pub mod legacy_use; pub mod log_files; pub mod listeners; pub mod protocol_policy; +pub mod sharing_policy; pub mod tenant_protocol_policy; diff --git a/crates/features/src/security/sharing_policy.rs b/crates/features/src/security/sharing_policy.rs new file mode 100644 index 0000000..105ab14 --- /dev/null +++ b/crates/features/src/security/sharing_policy.rs @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:SharingPolicy`, whether people may share their own mail and add +//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14). +//! +//! Two levels, as the legacy-protocols switch has: the server's policy, and +//! one per tenant that can only be stricter. Stored as JSON in the fork's +//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant; +//! unset reads as the defaults, which are on, so a server keeps today's +//! behavior until someone turns it off. +//! +//! "Off" refuses new shares and stops honoring the ones already made, which +//! stay stored, so turning it back on restores them (John, 2026-10-05). +//! Group membership and shared mailboxes aren't users' shares and are never +//! affected. + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +/// One level's switches. `None` on a tenant means "as the server says". +#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct SharingPolicy { + /// People may share their own mail folders (MA-11). Default on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mail_sharing: Option, + /// People may add their other accounts to the webmail (MA-B). Default on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub add_accounts: Option, + /// Seconds since the epoch, and who: the console shows them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_by: Option, +} + +/// What applies to one account: the server's switch, narrowed by its +/// tenant's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Effective { + pub mail_sharing: bool, + pub add_accounts: bool, +} + +impl Default for Effective { + fn default() -> Self { + Effective { + mail_sharing: true, + add_accounts: true, + } + } +} + +/// A tenant can be stricter than the server, never looser (MA-C). +pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective { + let server_mail = server.mail_sharing.unwrap_or(true); + let server_add = server.add_accounts.unwrap_or(true); + Effective { + mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true), + add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true), + } +} + +/// Why a tenant can't turn a switch on: the server has it off. +pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> { + if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) { + return Some("The server has mail sharing off; a tenant can only be stricter."); + } + if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) { + return Some("The server has adding accounts off; a tenant can only be stricter."); + } + None +} + +fn key(tenant_id: Option) -> ValueClass { + let mut key = Vec::with_capacity(6); + match tenant_id { + None => key.extend_from_slice(b"Wp"), + Some(tenant_id) => { + key.extend_from_slice(b"Wt"); + key.extend_from_slice(&tenant_id.to_be_bytes()); + } + } + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +struct Json(SharingPolicy); + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .caused_by(trc::location!()) + .reason(err) + }) + } +} + +/// The server's policy (`None`) or a tenant's. +pub async fn get(data: &Store, tenant_id: Option) -> trc::Result { + Ok(data + .get_value::(ValueKey::from(key(tenant_id))) + .await + .caused_by(trc::location!())? + .map(|Json(policy)| policy) + .unwrap_or_default()) +} + +/// What applies to an account in `tenant_id`. +pub async fn effective_for(data: &Store, tenant_id: Option) -> trc::Result { + let server = get(data, None).await?; + let tenant = match tenant_id { + Some(tenant_id) => Some(get(data, Some(tenant_id)).await?), + None => None, + }; + Ok(effective(&server, tenant.as_ref())) +} + +/// Stores a policy. +pub async fn set(data: &Store, tenant_id: Option, policy: &SharingPolicy) -> trc::Result<()> { + let bytes = serde_json::to_vec(policy).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?; + let mut batch = BatchBuilder::new(); + batch.set(key(tenant_id), bytes); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn on_off(mail: Option, add: Option) -> SharingPolicy { + SharingPolicy { + mail_sharing: mail, + add_accounts: add, + ..Default::default() + } + } + + #[test] + fn unset_is_on() { + assert_eq!(effective(&SharingPolicy::default(), None), Effective::default()); + assert_eq!( + effective(&SharingPolicy::default(), Some(&SharingPolicy::default())), + Effective::default() + ); + } + + #[test] + fn a_tenant_is_only_ever_stricter() { + // The server off wins over a tenant on + let server = on_off(Some(false), None); + let tenant = on_off(Some(true), Some(false)); + let e = effective(&server, Some(&tenant)); + assert!(!e.mail_sharing); + assert!(!e.add_accounts, "the tenant's own off holds"); + assert!(looser_than_server(&server, &tenant).is_some()); + // A tenant off under a server on + let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None))); + assert!(!e.mail_sharing && e.add_accounts); + assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none()); + } + + #[test] + fn keys_stay_apart() { + let ValueClass::Any(server) = key(None) else { panic!() }; + let ValueClass::Any(tenant) = key(Some(7)) else { panic!() }; + assert_eq!(server.key, b"Wp"); + assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]); + } +} diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index b738ad2..800a2f0 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -377,6 +377,34 @@ impl Session { } } + // inbuxa: MA-C: with mail sharing off, nobody here starts or + // widens a share (narrowing or ending one is always allowed) + let had = current_mailbox + .inner + .acls + .iter() + .find(|item| item.account_id == acl_account_id) + .map_or(0, |item| item.grants.clone().into_inner()); + let has = mailbox + .acls + .iter() + .find(|item| item.account_id == acl_account_id) + .map_or(0, |item| item.grants.clone().into_inner()); + if has & !had != 0 + && !access_token.has_permission(Permission::Impersonate) + && !data + .server + .mail_sharing_allowed(mailbox_id.account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + return Err(trc::ImapEvent::Error + .into_err() + .details("Your organization has turned off sharing mail folders.") + .code(ResponseCode::NoPerm) + .id(arguments.tag.to_string())); + } + if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item { return Err(trc::ImapEvent::Error .into_err() diff --git a/crates/jmap-proto/src/object/inbuxa_sharing_policy.rs b/crates/jmap-proto/src/object/inbuxa_sharing_policy.rs new file mode 100644 index 0000000..4b412ed --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_sharing_policy.rs @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:SharingPolicy/get` and `/set` under `urn:inbuxa:jmap`: whether +//! people may share their own mail and add other accounts to the webmail +//! (multi-account spec, MA-C). The server's policy has the singleton id; +//! each tenant's has the tenant's id. +//! +//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client +//! that sets them is answered with `invalidProperties`. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct SharingPolicy; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SharingPolicyProperty { + Id, + /// Server-set: the tenant this is the policy of, or null for the server's. + TenantId, + /// `enabled` or `disabled`: people may share their own mail folders. + MailSharing, + /// `enabled` or `disabled`: people may add other accounts to the webmail. + AddAccounts, + ChangedAt, + ChangedBy, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SharingPolicyValue { + Id(Id), +} + +impl Property for SharingPolicyProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + SharingPolicyProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SharingPolicyProperty::Id => "id", + SharingPolicyProperty::TenantId => "tenantId", + SharingPolicyProperty::MailSharing => "mailSharing", + SharingPolicyProperty::AddAccounts => "addAccounts", + SharingPolicyProperty::ChangedAt => "changedAt", + SharingPolicyProperty::ChangedBy => "changedBy", + } + .into() + } +} + +impl SharingPolicyProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => SharingPolicyProperty::Id, + b"tenantId" => SharingPolicyProperty::TenantId, + b"mailSharing" => SharingPolicyProperty::MailSharing, + b"addAccounts" => SharingPolicyProperty::AddAccounts, + b"changedAt" => SharingPolicyProperty::ChangedAt, + b"changedBy" => SharingPolicyProperty::ChangedBy, + ) + } +} + +impl SharingPolicyProperty { + /// Whether this property is the server's to say. A client that sets one + /// is answered with `invalidProperties`. + pub fn is_server_set(&self) -> bool { + matches!( + self, + SharingPolicyProperty::TenantId + | SharingPolicyProperty::ChangedAt + | SharingPolicyProperty::ChangedBy + ) + } +} + +impl FromStr for SharingPolicyProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + SharingPolicyProperty::parse(s).ok_or(()) + } +} + +impl Element for SharingPolicyValue { + type Property = SharingPolicyProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(SharingPolicyProperty::Id) => { + Id::from_str(value).ok().map(SharingPolicyValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + SharingPolicyValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for SharingPolicy { + type Property = SharingPolicyProperty; + + type Element = SharingPolicyValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = SharingPolicyProperty::Id; +} + +impl From for SharingPolicyValue { + fn from(id: Id) -> Self { + SharingPolicyValue::Id(id) + } +} + +impl JmapObjectId for SharingPolicyValue { + fn as_id(&self) -> Option { + match self { + SharingPolicyValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + SharingPolicyValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = SharingPolicyValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for SharingPolicyProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 9a965f5..fe691ba 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -38,6 +38,7 @@ pub mod inbuxa_hold_export; // inbuxa: legal hold exports pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant +pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod file_node; pub mod identity; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index a944476..942dbf3 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -109,6 +109,9 @@ impl Response<'_> { GetResponseMethod::TenantProtocolPolicy(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::SharingPolicy(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 0032fd4..bdca276 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -64,6 +64,9 @@ impl Response<'_> { GetRequestMethod::TenantProtocolPolicy(request) => { request.resolve_references(self)? } + GetRequestMethod::SharingPolicy(request) => { + request.resolve_references(self)? + } GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -158,6 +161,9 @@ impl Response<'_> { SetRequestMethod::TenantProtocolPolicy(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::SharingPolicy(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index de11758..becfccf 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -183,6 +183,13 @@ pub struct InbuxaAccountCapabilities { /// spec, EX-1 to EX-4). #[serde(rename(serialize = "aiExplain"))] pub ai_explain: bool, + /// MA-C: whether the principal may share their own mail folders, and + /// add other accounts to the webmail: the stricter of the server's + /// switch and its tenant's. + #[serde(rename(serialize = "mailSharing"))] + pub mail_sharing: bool, + #[serde(rename(serialize = "addAccounts"))] + pub add_accounts: bool, } #[derive(Debug, Clone, serde::Serialize)] diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index d9ef681..4c86ae1 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -77,6 +77,7 @@ pub enum MethodObject { JournalExport, JournalVerification, TenantProtocolPolicy, + SharingPolicy, } impl MethodObject { @@ -124,6 +125,7 @@ impl MethodObject { | MethodObject::JournalVerification => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, + MethodObject::SharingPolicy => Capability::Inbuxa, } } } @@ -344,6 +346,12 @@ impl MethodName { (MethodFunction::Set, MethodObject::TenantProtocolPolicy) => { "inbuxa:TenantProtocolPolicy/set" } + (MethodFunction::Get, MethodObject::SharingPolicy) => { + "inbuxa:SharingPolicy/get" + } + (MethodFunction::Set, MethodObject::SharingPolicy) => { + "inbuxa:SharingPolicy/set" + } (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -504,6 +512,8 @@ impl MethodName { "inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set), "inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get), "inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set), + "inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get), + "inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -578,6 +588,7 @@ impl Display for MethodObject { MethodObject::HoldExport => "inbuxa:HoldExport", MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy", MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy", + MethodObject::SharingPolicy => "inbuxa:SharingPolicy", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 48c0de7..eb7be0c 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -134,6 +134,9 @@ pub enum GetRequestMethod { TenantProtocolPolicy( Box>, ), + SharingPolicy( + Box>, + ), } #[derive(Debug)] @@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> { TenantProtocolPolicy( Box>, ), + SharingPolicy( + Box>, + ), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 518e8f3..f4723a3 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::SharingPolicy) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Get(GetRequestMethod::SharingPolicy(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::SharingPolicy) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Set(SetRequestMethod::SharingPolicy(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 500ab4e..28de17f 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -121,6 +121,9 @@ pub enum GetResponseMethod { TenantProtocolPolicy( GetResponse, ), + SharingPolicy( + GetResponse, + ), } #[derive(Debug, serde::Serialize)] @@ -166,6 +169,9 @@ pub enum SetResponseMethod { TenantProtocolPolicy( Box>, ), + SharingPolicy( + Box>, + ), } #[derive(Debug, serde::Serialize)] @@ -352,6 +358,16 @@ impl<'x> From From> + for ResponseMethod<'x> +{ + fn from( + value: GetResponse, + ) -> Self { + ResponseMethod::Get(GetResponseMethod::SharingPolicy(value)) + } +} + impl<'x> From> for ResponseMethod<'x> { @@ -362,6 +378,16 @@ impl<'x> From From> + for ResponseMethod<'x> +{ + fn from( + value: SetResponse, + ) -> Self { + ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::AiLimits(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 4884c05..674c78e 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken { // sign-in on the tenant's domains, so it takes the domain's // permissions, which a tenant administrator already holds. GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet, + // inbuxa: MA-C, who may share mail: a tenant administrator + // manages their tenant's, so the domain's permissions; the + // server's own also needs sysSharingUpdate (see the method) + GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken { Permission::SysDomainUpdate, Permission::SysDomainUpdate, ), + // inbuxa: MA-C, who may share mail, with the domain's + SetRequestMethod::SharingPolicy(s) => validate_set( + s, + self, + Permission::SysDomainUpdate, + Permission::SysDomainUpdate, + Permission::SysDomainUpdate, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::JournalExport | MethodObject::JournalVerification | MethodObject::ProtocolPolicy - | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, + | MethodObject::TenantProtocolPolicy + | MethodObject::SharingPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 910aa63..b847aed 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -317,6 +317,9 @@ impl RequestHandler for Server { SetResponseMethod::TenantProtocolPolicy(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::SharingPolicy(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -574,6 +577,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail) + GetRequestMethod::SharingPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::sharing_policy::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -1132,6 +1142,23 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail) + SetRequestMethod::SharingPolicy(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + // inbuxa: AU-1.2, AU-3 + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| Box::pin(crate::inbuxa::sharing_policy::set(self, access_token, req)), + ) + .await? + .into() + } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 38e1eda..b7c0b63 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -80,6 +80,13 @@ impl SessionHandler for Server { let ai_explain = access_token.has_permission(Permission::SysAiExplain) && access_token.tenant_id().is_none() && self.ai_explain_model(&self.ai_limits().await).await.is_some(); + // inbuxa: MA-C: what the sharing switches leave this principal + let sharing = inbuxa_features::security::sharing_policy::effective_for( + self.store(), + access_token.tenant_id(), + ) + .await + .caused_by(trc::location!())?; account.account_capabilities.append( Capability::Inbuxa, Capabilities::Inbuxa(InbuxaAccountCapabilities { @@ -87,6 +94,8 @@ impl SessionHandler for Server { legacy_protocols, legacy_allowed, ai_explain, + mail_sharing: sharing.mail_sharing, + add_accounts: sharing.add_accounts, }), ); // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index f18b7de..d0ab35b 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -439,6 +439,7 @@ impl IntermediateChangesResponse { | MethodObject::HeldMessage | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy + | MethodObject::SharingPolicy | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index d058281..2e420ef 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -28,6 +28,7 @@ pub mod webhook_test; pub mod explanation; pub mod protocol_policy; pub mod tenant_protocol_policy; +pub mod sharing_policy; pub mod deleted_account; pub mod fastmail; pub mod masked_email; diff --git a/crates/jmap/src/inbuxa/sharing_policy.rs b/crates/jmap/src/inbuxa/sharing_policy.rs new file mode 100644 index 0000000..7fb2a4c --- /dev/null +++ b/crates/jmap/src/inbuxa/sharing_policy.rs @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:SharingPolicy/get` and `/set`: whether people may share their own +//! mail and add other accounts to the webmail (multi-account spec, MA-C). +//! +//! The server's policy has the singleton id; each tenant's has the tenant's +//! id. At server level `/get` with no ids answers with the server's and every +//! tenant's; inside a tenant, with the server's (to read) and its own tenant's +//! (MT-1). Only a server administrator holding `sysSharingUpdate` changes the +//! server's; a tenant's administrator changes their tenant's, and can only be +//! stricter than the server. +//! +//! A change rebuilds every access token, here and on every node: what a share +//! still gives is worked out when a token is built. + +use common::{Server, auth::AccessToken, ipc::BroadcastEvent}; +use inbuxa_features::{ + security::sharing_policy::{self, SharingPolicy as Policy, looser_than_server}, + tenancy::quota::all_tenants, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_sharing_policy::{SharingPolicy, SharingPolicyProperty as P, SharingPolicyValue}, + request::IntoValid, +}; +use jmap_tools::{Key, Map, Value}; +use registry::schema::enums::Permission; +use types::id::Id; + +type PValue = Value<'static, P, SharingPolicyValue>; + +const ALL: &[P] = &[P::Id, P::TenantId, P::MailSharing, P::AddAccounts, P::ChangedAt, P::ChangedBy]; + +fn switch_str(on: Option) -> &'static str { + if on.unwrap_or(true) { "enabled" } else { "disabled" } +} + +fn to_value(tenant_id: Option, policy: &Policy, properties: &[P]) -> PValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(SharingPolicyValue::Id( + tenant_id.map_or_else(Id::singleton, Id::from), + )), + P::TenantId => tenant_id + .map(|t| Value::Element(SharingPolicyValue::Id(Id::from(t)))) + .unwrap_or(Value::Null), + P::MailSharing => Value::Str(switch_str(policy.mail_sharing).into()), + P::AddAccounts => Value::Str(switch_str(policy.add_accounts).into()), + P::ChangedAt => policy + .changed_at + .map(|at| Value::Number(at.into())) + .unwrap_or(Value::Null), + P::ChangedBy => policy + .changed_by + .as_ref() + .map(|by| Value::Str(by.clone().into())) + .unwrap_or(Value::Null), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// The tenants this principal may reach: its own inside a tenant (MT-1), +/// every tenant at server level. +async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result> { + match access_token.tenant_id() { + Some(tenant_id) => Ok(vec![tenant_id]), + None => all_tenants(server.registry()).await, + } +} + +/// Which policy an id names: `None` for the server's. +fn target(id: Id) -> Option { + if id.is_singleton() { None } else { Some(id.document_id()) } +} + +/// `inbuxa:SharingPolicy/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let reachable = reachable(server, access_token).await?; + let wanted: Vec = match ids { + None => std::iter::once(Id::singleton()) + .chain(reachable.iter().map(|t| Id::from(*t))) + .collect(), + Some(ids) => ids, + }; + let data = &server.core.storage.data; + for id in wanted { + match target(id) { + None => { + let policy = sharing_policy::get(data, None).await?; + response.list.push(to_value(None, &policy, &properties)); + } + Some(tenant_id) if reachable.contains(&tenant_id) => { + let policy = sharing_policy::get(data, Some(tenant_id)).await?; + response.list.push(to_value(Some(tenant_id), &policy, &properties)); + } + Some(_) => response.push_not_found(id), + } + } + Ok(response) +} + +/// `inbuxa:SharingPolicy/set`: turns switches. `null` puts one back to its +/// default, on (as far as the server allows). +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, SharingPolicy>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response.not_created.append( + client_id, + SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."), + ); + } + + let reachable = reachable(server, access_token).await?; + let data = &server.core.storage.data; + let mut changed = false; + for (id, value) in request.unwrap_update().into_valid() { + let tenant_id = target(id); + match tenant_id { + None if access_token.tenant_id().is_some() + || !access_token.has_permission(Permission::SysSharingUpdate) => + { + response.not_updated.append( + id, + SetError::forbidden() + .with_description("Only a server administrator changes the server's sharing policy."), + ); + continue; + } + Some(tenant_id) if !reachable.contains(&tenant_id) => { + response.not_updated.append(id, SetError::not_found()); + continue; + } + _ => {} + } + + let previous = sharing_policy::get(data, tenant_id).await?; + let mut policy = previous.clone(); + let mut error = None; + for (key, value) in value.into_expanded_object() { + let parsed = match value { + Value::Null => Ok(None), + Value::Str(s) if s == "enabled" => Ok(Some(true)), + Value::Str(s) if s == "disabled" => Ok(Some(false)), + _ => Err("must be enabled or disabled".to_string()), + }; + let result = match &key { + Key::Property(P::MailSharing) => parsed.map(|v| policy.mail_sharing = v), + Key::Property(P::AddAccounts) => parsed.map(|v| policy.add_accounts = v), + Key::Property(P::Id) => Err("is immutable".to_string()), + Key::Property(_) => Err("is set by the server".to_string()), + _ => Err("is not a property of inbuxa:SharingPolicy".to_string()), + }; + if let Err(why) = result { + error = Some( + SetError::invalid_properties() + .with_property(key.into_owned()) + .with_description(why), + ); + break; + } + } + if let Some(error) = error { + response.not_updated.append(id, error); + continue; + } + + // A tenant can only be stricter than the server + if tenant_id.is_some() + && let Some(why) = looser_than_server(&sharing_policy::get(data, None).await?, &policy) + { + response + .not_updated + .append(id, SetError::forbidden().with_description(why)); + continue; + } + + if policy.mail_sharing != previous.mail_sharing || policy.add_accounts != previous.add_accounts { + policy.changed_at = Some(store::write::now() * 1000); + policy.changed_by = Some(Id::from(access_token.account_id()).to_string()); + sharing_policy::set(data, tenant_id, &policy).await?; + changed = true; + } + response.updated.append(id, None); + } + + if changed { + // Shares are honored, or not, as tokens are built + server.invalidate_all_local_caches(); + server.cluster_broadcast(BroadcastEvent::CacheInvalidateAll).await; + } + Ok(response) +} diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 94b92fe..07dc784 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -31,7 +31,7 @@ use jmap_proto::{ types::state::State, }; use jmap_tools::{JsonPointerItem, Key, Map, Value}; -use registry::schema::enums::StorageQuota; +use registry::schema::enums::{Permission, StorageQuota}; use std::future::Future; use store::{ ValueKey, @@ -622,6 +622,27 @@ impl MailboxSet for Server { ))); } + // inbuxa: MA-C: with mail sharing off, nobody here starts or + // widens a share (narrowing or ending one is always allowed) + let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default(); + let widens = changes.acls.iter().any(|grant| { + let had = before + .iter() + .find(|old| old.account_id == grant.account_id) + .map_or(0, |old| old.grants.clone().into_inner()); + grant.grants.clone().into_inner() & !had != 0 + }); + if widens + && !ctx.access_token.has_permission(Permission::Impersonate) + && !self.mail_sharing_allowed(ctx.account_id).await? + { + return Ok(Err(SetError::forbidden() + .with_property(MailboxProperty::ShareWith) + .with_description( + "Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.", + ))); + } + if !changes.acls.is_empty() && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await { diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 0c0c0a2..1d96c99 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -244,6 +244,16 @@ retention = "unbounded" changedBy = ["identifier"] recentLegacyUse = ["identifier", "metadata"] +[object."inbuxa:SharingPolicy"] +file = "inbuxa_sharing_policy.rs" +default = "none" +whose = ["administrator", "holder"] +where = ["data-store"] +scope = "tenant" +retention = "unbounded" +[object."inbuxa:SharingPolicy".properties] +changedBy = ["identifier"] + [object."inbuxa:AiLimits"] file = "inbuxa_ai_limits.rs" default = "none" diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index 2679c64..eee2748 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) { // inbuxa: MT-22, the logo that applies to the account, and // LP-19, whether the legacy protocols are open to it, and // ai-explain EX-1, whether Explain can be offered - "urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false }, + "urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false, "mailSharing": true, "addAccounts": true }, "https://www.fastmail.com/dev/maskedemail": {} } } diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 1db96a2..d0f7d32 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -12,6 +12,7 @@ pub mod ai; pub mod ai_calibration; pub mod ai_explain; pub mod account_lock; // inbuxa: account lock with delegation +pub mod sharing_policy; // inbuxa: MA-C, who may share mail pub mod legal_hold; // inbuxa: legal hold pub mod compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules diff --git a/tests/src/system/sharing_policy.rs b/tests/src/system/sharing_policy.rs new file mode 100644 index 0000000..a6f4e5a --- /dev/null +++ b/tests/src/system/sharing_policy.rs @@ -0,0 +1,237 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Who may share mail (multi-account spec, MA-C): the server's switch and a +//! tenant's, which can only be stricter. Off refuses new shares and stops +//! honoring old ones, which come back when it's on again; locks and shared +//! mailboxes are never affected. + +use crate::utils::{account::Account, server::TestServerBuilder}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, +}; +use serde_json::{Value, json}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + "urn:inbuxa:jmap", +]; + +impl Account { + async fn one(&self, method: &str, arguments: Value) -> Value { + let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await; + response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)) + } + + async fn policy(&self, update: Value) -> Value { + self.one( + "inbuxa:SharingPolicy/set", + json!({"accountId": self.id_string(), "update": update}), + ) + .await[1] + .clone() + } + + async fn inbox(&self) -> String { + let response = self + .one( + "Mailbox/get", + json!({"accountId": self.id_string(), "ids": null, "properties": ["role"]}), + ) + .await; + response[1]["list"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "inbox") + .unwrap_or_else(|| panic!("no Inbox: {response}"))["id"] + .as_str() + .unwrap() + .to_string() + } + + /// Shares the Inbox with `with` (read), or stops with `None` rights. + async fn share_inbox(&self, with: &Account, read: bool) -> Value { + let inbox = self.inbox().await; + let rights = if read { json!({"mayReadItems": true}) } else { Value::Null }; + self.one( + "Mailbox/set", + json!({"accountId": self.id_string(), + "update": {inbox: {format!("shareWith/{}", with.id_string()): rights}}}), + ) + .await[1] + .clone() + } + + async fn sees(&self, other: &Account) -> bool { + self.jmap_session_object().await.0["accounts"] + .get(other.id_string()) + .is_some() + } + + async fn mail_sharing(&self) -> Value { + self.jmap_session_object().await.0["accounts"][self.id_string()]["accountCapabilities"] + ["urn:inbuxa:jmap"]["mailSharing"] + .clone() + } +} + +/// Runs these tests alone: `cargo test -p tests sharing_policy_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn sharing_policy_tests() { + let mut test = TestServerBuilder::new("sharing_policy_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.org").await; + println!("Running sharing policy tests..."); + + let tenant = admin + .registry_create_object(Tenant { + name: "School".to_string(), + ..Default::default() + }) + .await; + admin + .registry_create_object(Domain { + name: "school.example.org".to_string(), + is_enabled: true, + member_tenant_id: Some(tenant), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await; + let ann = admin + .create_user_account("ann@school.example.org", "ann-secret-6610", "Ann", &[], vec![]) + .await; + let ben = admin + .create_user_account("ben@school.example.org", "ben-secret-6611", "Ben", &[], vec![]) + .await; + let head = admin + .create_user_account("head@school.example.org", "head-secret-6612", "Head", &[], vec![]) + .await; + admin + .registry_update_object( + ObjectType::Account, + head.id(), + json!({Property::Roles: UserRoles::Admin}), + ) + .await; + let carl = admin + .create_user_account("carl@example.org", "carl-secret-6613", "Carl", &[], vec![]) + .await; + // Shares never cross tenants (MT-3), so Carl's neighbour is outside too + let dan = admin + .create_user_account("dan@example.org", "dan-secret-6614", "Dan", &[], vec![]) + .await; + let tenant_id = tenant.to_string(); + + // MA-10: on by default + assert_eq!(ann.mail_sharing().await, true, "MA-10"); + let response = ann.share_inbox(&ben, true).await; + assert!(response["updated"].is_object(), "MA-10: {response}"); + assert!(ben.sees(&ann).await, "MA-10: the share gives nothing"); + + // The school turns mail sharing off, as its own administrator + let response = head + .policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}})) + .await; + assert!(response["updated"].is_object(), "MA-13: {response}"); + // ...but can't touch the server's + let response = head + .policy(json!({"singleton": {"mailSharing": "disabled"}})) + .await; + assert_eq!(response["notUpdated"]["singleton"]["type"], "forbidden", "MA-13: {response}"); + + // MA-11: what was shared gives nothing now, and nothing new is shared + assert_eq!(ann.mail_sharing().await, false, "MA-11"); + assert!(!ben.sees(&ann).await, "MA-11: an old share still honored"); + let response = ann.share_inbox(&head, true).await; + assert_eq!( + response["notUpdated"].as_object().and_then(|o| o.values().next()).map(|e| e["type"].clone()), + Some(json!("forbidden")), + "MA-11: {response}" + ); + // MA-12: a shared mailbox isn't anyone's share, and keeps working + let office = admin + .create_user_account("office@school.example.org", "office-secret-6615", "Office", &[], vec![]) + .await; + let response = admin + .one( + "inbuxa:AccountLock/set", + json!({"accountId": admin.id_string(), "create": {"o": {"accountId": office.id_string(), + "kind": "sharedMailbox", + "delegates": [{"accountId": ben.id_string(), "access": "organize"}]}}}), + ) + .await; + assert!(response[1]["created"]["o"].is_object(), "MA-12: {response}"); + assert!(ben.sees(&office).await, "MA-12: the switch reached a shared mailbox"); + + // Outside the school nothing changed + let response = carl.share_inbox(&dan, true).await; + assert!(response["updated"].is_object(), "MA-C: another tenant's switch reached Carl: {response}"); + assert!(dan.sees(&carl).await, "MA-C"); + + // A tenant can only be stricter than the server + let response = admin + .policy(json!({"singleton": {"mailSharing": "disabled"}})) + .await; + assert!(response["updated"].is_object(), "MA-C: {response}"); + let response = head + .policy(json!({tenant_id.as_str(): {"mailSharing": "enabled"}})) + .await; + assert_eq!( + response["notUpdated"][tenant_id.as_str()]["type"], + "forbidden", + "MA-C: {response}" + ); + assert!(!dan.sees(&carl).await, "MA-C: the server's switch didn't reach Carl's share"); + + // Turned back on, the old shares are honored again + admin + .policy(json!({"singleton": {"mailSharing": null}})) + .await; + head.policy(json!({tenant_id.as_str(): {"mailSharing": null}})) + .await; + assert!(ben.sees(&ann).await, "MA-C: an old share didn't come back"); + assert!(dan.sees(&carl).await, "MA-C"); + + // Ending a share is always allowed, even while sharing is off + head.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}})) + .await; + let response = ann.share_inbox(&ben, false).await; + assert!(response["updated"].is_object(), "MA-11: ending a share refused: {response}"); + head.policy(json!({tenant_id.as_str(): {"mailSharing": null}})) + .await; + assert!(!ben.sees(&ann).await, "MA-11: the ended share came back"); + + // MA-14: every change is in the audit log + let query = admin + .one( + "inbuxa:AuditEvent/query", + json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:SharingPolicy"}}), + ) + .await; + assert!( + query[1]["ids"].as_array().is_some_and(|ids| ids.len() >= 5), + "MA-14: {query}" + ); + + if test.is_reset() { + test.temp_dir.delete(); + } +}