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
@@ -15,4 +15,5 @@ pub mod legacy_use;
|
||||
pub mod log_files;
|
||||
pub mod listeners;
|
||||
pub mod protocol_policy;
|
||||
pub mod sharing_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:SharingPolicy`, whether people may share their own mail and add
|
||||
//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14).
|
||||
//!
|
||||
//! Two levels, as the legacy-protocols switch has: the server's policy, and
|
||||
//! one per tenant that can only be stricter. Stored as JSON in the fork's
|
||||
//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant;
|
||||
//! unset reads as the defaults, which are on, so a server keeps today's
|
||||
//! behavior until someone turns it off.
|
||||
//!
|
||||
//! "Off" refuses new shares and stops honoring the ones already made, which
|
||||
//! stay stored, so turning it back on restores them (John, 2026-10-05).
|
||||
//! Group membership and shared mailboxes aren't users' shares and are never
|
||||
//! affected.
|
||||
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
/// One level's switches. `None` on a tenant means "as the server says".
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SharingPolicy {
|
||||
/// People may share their own mail folders (MA-11). Default on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail_sharing: Option<bool>,
|
||||
/// People may add their other accounts to the webmail (MA-B). Default on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub add_accounts: Option<bool>,
|
||||
/// Seconds since the epoch, and who: the console shows them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub changed_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub changed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// What applies to one account: the server's switch, narrowed by its
|
||||
/// tenant's.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Effective {
|
||||
pub mail_sharing: bool,
|
||||
pub add_accounts: bool,
|
||||
}
|
||||
|
||||
impl Default for Effective {
|
||||
fn default() -> Self {
|
||||
Effective {
|
||||
mail_sharing: true,
|
||||
add_accounts: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tenant can be stricter than the server, never looser (MA-C).
|
||||
pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective {
|
||||
let server_mail = server.mail_sharing.unwrap_or(true);
|
||||
let server_add = server.add_accounts.unwrap_or(true);
|
||||
Effective {
|
||||
mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true),
|
||||
add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a tenant can't turn a switch on: the server has it off.
|
||||
pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> {
|
||||
if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) {
|
||||
return Some("The server has mail sharing off; a tenant can only be stricter.");
|
||||
}
|
||||
if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) {
|
||||
return Some("The server has adding accounts off; a tenant can only be stricter.");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn key(tenant_id: Option<u32>) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
match tenant_id {
|
||||
None => key.extend_from_slice(b"Wp"),
|
||||
Some(tenant_id) => {
|
||||
key.extend_from_slice(b"Wt");
|
||||
key.extend_from_slice(&tenant_id.to_be_bytes());
|
||||
}
|
||||
}
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
struct Json(SharingPolicy);
|
||||
|
||||
impl Deserialize for Json {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's policy (`None`) or a tenant's.
|
||||
pub async fn get(data: &Store, tenant_id: Option<u32>) -> trc::Result<SharingPolicy> {
|
||||
Ok(data
|
||||
.get_value::<Json>(ValueKey::from(key(tenant_id)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(policy)| policy)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// What applies to an account in `tenant_id`.
|
||||
pub async fn effective_for(data: &Store, tenant_id: Option<u32>) -> trc::Result<Effective> {
|
||||
let server = get(data, None).await?;
|
||||
let tenant = match tenant_id {
|
||||
Some(tenant_id) => Some(get(data, Some(tenant_id)).await?),
|
||||
None => None,
|
||||
};
|
||||
Ok(effective(&server, tenant.as_ref()))
|
||||
}
|
||||
|
||||
/// Stores a policy.
|
||||
pub async fn set(data: &Store, tenant_id: Option<u32>, policy: &SharingPolicy) -> trc::Result<()> {
|
||||
let bytes = serde_json::to_vec(policy).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.caused_by(trc::location!())
|
||||
.reason(err)
|
||||
})?;
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(key(tenant_id), bytes);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn on_off(mail: Option<bool>, add: Option<bool>) -> SharingPolicy {
|
||||
SharingPolicy {
|
||||
mail_sharing: mail,
|
||||
add_accounts: add,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_is_on() {
|
||||
assert_eq!(effective(&SharingPolicy::default(), None), Effective::default());
|
||||
assert_eq!(
|
||||
effective(&SharingPolicy::default(), Some(&SharingPolicy::default())),
|
||||
Effective::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_is_only_ever_stricter() {
|
||||
// The server off wins over a tenant on
|
||||
let server = on_off(Some(false), None);
|
||||
let tenant = on_off(Some(true), Some(false));
|
||||
let e = effective(&server, Some(&tenant));
|
||||
assert!(!e.mail_sharing);
|
||||
assert!(!e.add_accounts, "the tenant's own off holds");
|
||||
assert!(looser_than_server(&server, &tenant).is_some());
|
||||
// A tenant off under a server on
|
||||
let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None)));
|
||||
assert!(!e.mail_sharing && e.add_accounts);
|
||||
assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_stay_apart() {
|
||||
let ValueClass::Any(server) = key(None) else { panic!() };
|
||||
let ValueClass::Any(tenant) = key(Some(7)) else { panic!() };
|
||||
assert_eq!(server.key, b"Wp");
|
||||
assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user