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).
238 lines
8.5 KiB
Rust
238 lines
8.5 KiB
Rust
/*
|
|
* 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();
|
|
}
|
|
}
|