Merge pull request 'Who may share mail: a server switch, and a tenant's that can only be stricter' (#149) from feat/sharing-policy into main
This commit was merged in pull request #149.
This commit is contained in:
commit
4f25927d18
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