Who may share mail: a server switch, and a tenant's that can only be stricter
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:
1 parent
50a03df30b
commit
fb785b8635
24 files changed
+1078
-3
No files matched your search
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
|
|||||||
use xxhash_rust::xxh3;
|
use xxhash_rust::xxh3;
|
||||||
|
|
||||||
impl Server {
|
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(
|
async fn build_access_token(
|
||||||
&self,
|
&self,
|
||||||
account: Account,
|
account: Account,
|
||||||
@@ -100,6 +126,9 @@ impl Server {
|
|||||||
.map(|m| m.id() as u32)
|
.map(|m| m.id() as u32)
|
||||||
.collect::<TinyVec<[u32; 3]>>();
|
.collect::<TinyVec<[u32; 3]>>();
|
||||||
let mut access_to: Vec<AccessTo> = Vec::new();
|
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 grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
||||||
for acl_item in self
|
for acl_item in self
|
||||||
.store()
|
.store()
|
||||||
@@ -120,6 +149,27 @@ impl Server {
|
|||||||
.caused_by(trc::location!()));
|
.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();
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
||||||
if acl.contains(Acl::Read) {
|
if acl.contains(Acl::Read) {
|
||||||
collections.insert(collection);
|
collections.insert(collection);
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ pub mod legacy_use;
|
|||||||
pub mod log_files;
|
pub mod log_files;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod tenant_protocol_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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
||||||
return Err(trc::ImapEvent::Error
|
return Err(trc::ImapEvent::Error
|
||||||
.into_err()
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_explanation; // inbuxa: "Explain this" with the local model
|
||||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
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 inbuxa_deleted_account; // inbuxa: undelete
|
||||||
pub mod file_node;
|
pub mod file_node;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::TenantProtocolPolicy(response) => {
|
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::SharingPolicy(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Principal(response) => {
|
GetResponseMethod::Principal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
}
|
}
|
||||||
|
GetRequestMethod::SharingPolicy(request) => {
|
||||||
|
request.resolve_references(self)?
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||||
@@ -158,6 +161,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::TenantProtocolPolicy(request) => {
|
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::SharingPolicy(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(request) => {
|
SetRequestMethod::AddressBook(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
|
|||||||
/// spec, EX-1 to EX-4).
|
/// spec, EX-1 to EX-4).
|
||||||
#[serde(rename(serialize = "aiExplain"))]
|
#[serde(rename(serialize = "aiExplain"))]
|
||||||
pub ai_explain: bool,
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ pub enum MethodObject {
|
|||||||
JournalExport,
|
JournalExport,
|
||||||
JournalVerification,
|
JournalVerification,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
|
SharingPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodObject {
|
impl MethodObject {
|
||||||
@@ -124,6 +125,7 @@ impl MethodObject {
|
|||||||
| MethodObject::JournalVerification => Capability::Inbuxa,
|
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
|
MethodObject::SharingPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,6 +346,12 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||||
"inbuxa:TenantProtocolPolicy/set"
|
"inbuxa:TenantProtocolPolicy/set"
|
||||||
}
|
}
|
||||||
|
(MethodFunction::Get, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/get"
|
||||||
|
}
|
||||||
|
(MethodFunction::Set, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/set"
|
||||||
|
}
|
||||||
(method, MethodObject::Registry(obj)) => {
|
(method, MethodObject::Registry(obj)) => {
|
||||||
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
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:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||||
|
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
|
||||||
|
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
|
||||||
|
|
||||||
).or_else(|| {
|
).or_else(|| {
|
||||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||||
@@ -578,6 +588,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
|
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
f.write_str("x:")?;
|
f.write_str("x:")?;
|
||||||
return f.write_str(obj.as_str());
|
return f.write_str(obj.as_str());
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ pub enum GetRequestMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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() {
|
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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() {
|
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ pub enum GetResponseMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -166,6 +169,9 @@ pub enum SetResponseMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[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>>
|
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||||
for ResponseMethod<'x>
|
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> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// sign-in on the tenant's domains, so it takes the domain's
|
// sign-in on the tenant's domains, so it takes the domain's
|
||||||
// permissions, which a tenant administrator already holds.
|
// permissions, which a tenant administrator already holds.
|
||||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
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::Principal(_) => Permission::JmapPrincipalGet,
|
||||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||||
@@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
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(
|
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
| MethodObject::JournalVerification
|
| MethodObject::JournalVerification
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::SharingPolicy(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AddressBook(set_response) => {
|
SetResponseMethod::AddressBook(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -574,6 +577,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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) => {
|
GetRequestMethod::Principal(req) => {
|
||||||
self.principal_get(*req, access_token).await?.into()
|
self.principal_get(*req, access_token).await?.into()
|
||||||
}
|
}
|
||||||
@@ -1132,6 +1142,23 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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) => {
|
SetRequestMethod::AddressBook(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
|
|||||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||||
&& access_token.tenant_id().is_none()
|
&& access_token.tenant_id().is_none()
|
||||||
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
&& 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(
|
account.account_capabilities.append(
|
||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||||
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
|
|||||||
legacy_protocols,
|
legacy_protocols,
|
||||||
legacy_allowed,
|
legacy_allowed,
|
||||||
ai_explain,
|
ai_explain,
|
||||||
|
mail_sharing: sharing.mail_sharing,
|
||||||
|
add_accounts: sharing.add_accounts,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub mod webhook_test;
|
|||||||
pub mod explanation;
|
pub mod explanation;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
pub mod tenant_protocol_policy;
|
pub mod tenant_protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod deleted_account;
|
pub mod deleted_account;
|
||||||
pub mod fastmail;
|
pub mod fastmail;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ use jmap_proto::{
|
|||||||
types::state::State,
|
types::state::State,
|
||||||
};
|
};
|
||||||
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
||||||
use registry::schema::enums::StorageQuota;
|
use registry::schema::enums::{Permission, StorageQuota};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use store::{
|
use store::{
|
||||||
ValueKey,
|
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()
|
if !changes.acls.is_empty()
|
||||||
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -244,6 +244,16 @@ retention = "unbounded"
|
|||||||
changedBy = ["identifier"]
|
changedBy = ["identifier"]
|
||||||
recentLegacyUse = ["identifier", "metadata"]
|
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"]
|
[object."inbuxa:AiLimits"]
|
||||||
file = "inbuxa_ai_limits.rs"
|
file = "inbuxa_ai_limits.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
// inbuxa: MT-22, the logo that applies to the account, and
|
// inbuxa: MT-22, the logo that applies to the account, and
|
||||||
// LP-19, whether the legacy protocols are open to it, and
|
// LP-19, whether the legacy protocols are open to it, and
|
||||||
// ai-explain EX-1, whether Explain can be offered
|
// 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": {}
|
"https://www.fastmail.com/dev/maskedemail": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod ai;
|
|||||||
pub mod ai_calibration;
|
pub mod ai_calibration;
|
||||||
pub mod ai_explain;
|
pub mod ai_explain;
|
||||||
pub mod account_lock; // inbuxa: account lock with delegation
|
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 legal_hold; // inbuxa: legal hold
|
||||||
pub mod compliance; // inbuxa: the compliance roles
|
pub mod compliance; // inbuxa: the compliance roles
|
||||||
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user