Who may share mail: a server switch, and a tenant's that can only be stricter
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 4m6s
github/ci (branch) GitHub Actions

A school, or any organization that doesn't want people's mailboxes
shared, can now turn that off (multi-account spec, MA-C). Two switches
at two levels, as the legacy-protocols switch has:

- mailSharing: people may share their own mail folders;
- addAccounts: people may add other accounts to the webmail (read by
  the webmail's account switcher, MA-B).

inbuxa:SharingPolicy/get and /set hold them: the server's policy has
the singleton id, each tenant's has the tenant's id. Both default to
on, so nothing changes until someone turns one off. A tenant's
administrator changes their own tenant's (the domain's permissions, as
for its protocols switch); only a server administrator with
sysSharingUpdate changes the server's; a tenant can never be looser
than the server (forbidden). Every change goes through the audit log,
and rebuilds every access token, here and on every node.

With mail sharing off for an account's tenant (or the server):

- Mailbox/set and IMAP SETACL refuse to start or widen a share
  (forbidden / NO [NOPERM]); narrowing or ending one is always allowed;
- shares already made give nothing while it is off: an access token
  leaves out mailbox grants from such an owner. They stay stored, so
  turning sharing back on restores them (John, 2026-10-05);
- a lock's and a shared mailbox's grants are an administrator's and
  always count, and group membership was never a share.

The session's own account says mailSharing and addAccounts, the
stricter of the two levels, so front ends can hide what is off.

Tests: a new sharing_policy suite with a school tenant, its own
administrator and two people outside it: on by default; the school's
administrator turns it off but can't touch the server's; an old share
stops working and a new one is refused while someone outside the school
is unaffected; a shared mailbox in the school keeps working; the server
off can't be loosened by the tenant; on again restores the old share;
ending a share works while off; and every change is audited. A unit
test covers the stricter-only rule. sharing_policy_tests, jmap_tests,
imap_tests, account_lock_tests and audit_log_tests pass (RocksDB).
This commit is contained in:
jcoffey-dev committed 2026-10-05 16:00:09 -07:00
1 parent 50a03df30b
commit fb785b8635
24 files changed
+1078 -3

No files matched your search

+50
View File
@@ -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<bool> {
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<bool> {
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::<TinyVec<[u32; 3]>>();
let mut access_to: Vec<AccessTo> = 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<Collection> = Bitmap::new();
if acl.contains(Acl::Read) {
collections.insert(collection);
+1
View File
@@ -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;
@@ -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<bool>,
/// 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<bool>,
/// Seconds since the epoch, and who: the console shows them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed_by: Option<String>,
}
/// 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<u32>) -> 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<Self> {
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<u32>) -> trc::Result<SharingPolicy> {
Ok(data
.get_value::<Json>(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<u32>) -> trc::Result<Effective> {
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<u32>, 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<bool>, add: Option<bool>) -> 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]);
}
}
+28
View File
@@ -377,6 +377,34 @@ impl<T: SessionStream> Session<T> {
}
}
// 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()
@@ -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<Self> {
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<Self> {
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<Self, Self::Err> {
SharingPolicyProperty::parse(s).ok_or(())
}
}
impl Element for SharingPolicyValue {
type Property = SharingPolicyProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
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<Id> for SharingPolicyValue {
fn from(id: Id) -> Self {
SharingPolicyValue::Id(id)
}
}
impl JmapObjectId for SharingPolicyValue {
fn as_id(&self) -> Option<Id> {
match self {
SharingPolicyValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
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<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -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;
+3
View File
@@ -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)
}
@@ -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)?
}
@@ -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)]
+11
View File
@@ -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());
+6
View File
@@ -134,6 +134,9 @@ pub enum GetRequestMethod {
TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
SharingPolicy(
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
}
#[derive(Debug)]
@@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> {
TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
SharingPolicy(
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
}
#[derive(Debug)]
+18
View File
@@ -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),
+26
View File
@@ -121,6 +121,9 @@ pub enum GetResponseMethod {
TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
),
SharingPolicy(
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
),
}
#[derive(Debug, serde::Serialize)]
@@ -166,6 +169,9 @@ pub enum SetResponseMethod {
TenantProtocolPolicy(
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
SharingPolicy(
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
),
}
#[derive(Debug, serde::Serialize)]
@@ -352,6 +358,16 @@ impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
for ResponseMethod<'x>
{
fn from(
value: GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
) -> Self {
ResponseMethod::Get(GetResponseMethod::SharingPolicy(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
for ResponseMethod<'x>
{
@@ -362,6 +378,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
for ResponseMethod<'x>
{
fn from(
value: SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
) -> Self {
ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
+14 -1
View File
@@ -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(),
},
+27
View File
@@ -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)?;
+9
View File
@@ -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
+1
View File
@@ -439,6 +439,7 @@ impl IntermediateChangesResponse {
| MethodObject::HeldMessage
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::SharingPolicy
| MethodObject::Registry(_) => unreachable!(),
})
}
+1
View File
@@ -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;
+225
View File
@@ -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<bool>) -> &'static str {
if on.unwrap_or(true) { "enabled" } else { "disabled" }
}
fn to_value(tenant_id: Option<u32>, 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<Vec<u32>> {
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<u32> {
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<SharingPolicy>,
) -> trc::Result<GetResponse<SharingPolicy>> {
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<Id> = 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<SetResponse<SharingPolicy>> {
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)
}
+22 -1
View File
@@ -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
{
+10
View File
@@ -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"
+1 -1
View File
@@ -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": {}
}
}
+1
View File
@@ -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
+237
View File
@@ -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("[email protected]").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("[email protected]", "ann-secret-6610", "Ann", &[], vec![])
.await;
let ben = admin
.create_user_account("[email protected]", "ben-secret-6611", "Ben", &[], vec![])
.await;
let head = admin
.create_user_account("[email protected]", "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("[email protected]", "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("[email protected]", "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("[email protected]", "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();
}
}