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
@@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken {
|
||||
// sign-in on the tenant's domains, so it takes the domain's
|
||||
// permissions, which a tenant administrator already holds.
|
||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||
// inbuxa: MA-C, who may share mail: a tenant administrator
|
||||
// manages their tenant's, so the domain's permissions; the
|
||||
// server's own also needs sysSharingUpdate (see the method)
|
||||
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
|
||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||
@@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
),
|
||||
// inbuxa: MA-C, who may share mail, with the domain's
|
||||
SetRequestMethod::SharingPolicy(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
),
|
||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
@@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::JournalExport
|
||||
| MethodObject::JournalVerification
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||
},
|
||||
|
||||
@@ -317,6 +317,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::SharingPolicy(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AddressBook(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -574,6 +577,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
|
||||
GetRequestMethod::SharingPolicy(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::sharing_policy::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
GetRequestMethod::Principal(req) => {
|
||||
self.principal_get(*req, access_token).await?.into()
|
||||
}
|
||||
@@ -1132,6 +1142,23 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
|
||||
SetRequestMethod::SharingPolicy(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
// inbuxa: AU-1.2, AU-3
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::sharing_policy::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::AddressBook(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||
|
||||
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
|
||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||
&& access_token.tenant_id().is_none()
|
||||
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
||||
// inbuxa: MA-C: what the sharing switches leave this principal
|
||||
let sharing = inbuxa_features::security::sharing_policy::effective_for(
|
||||
self.store(),
|
||||
access_token.tenant_id(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
account.account_capabilities.append(
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
|
||||
legacy_protocols,
|
||||
legacy_allowed,
|
||||
ai_explain,
|
||||
mail_sharing: sharing.mail_sharing,
|
||||
add_accounts: sharing.add_accounts,
|
||||
}),
|
||||
);
|
||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||
|
||||
@@ -439,6 +439,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::HeldMessage
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::SharingPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod webhook_test;
|
||||
pub mod explanation;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
pub mod sharing_policy;
|
||||
pub mod deleted_account;
|
||||
pub mod fastmail;
|
||||
pub mod masked_email;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
||||
use registry::schema::enums::StorageQuota;
|
||||
use registry::schema::enums::{Permission, StorageQuota};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
@@ -622,6 +622,27 @@ impl MailboxSet for Server {
|
||||
)));
|
||||
}
|
||||
|
||||
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||
// widens a share (narrowing or ending one is always allowed)
|
||||
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
|
||||
let widens = changes.acls.iter().any(|grant| {
|
||||
let had = before
|
||||
.iter()
|
||||
.find(|old| old.account_id == grant.account_id)
|
||||
.map_or(0, |old| old.grants.clone().into_inner());
|
||||
grant.grants.clone().into_inner() & !had != 0
|
||||
});
|
||||
if widens
|
||||
&& !ctx.access_token.has_permission(Permission::Impersonate)
|
||||
&& !self.mail_sharing_allowed(ctx.account_id).await?
|
||||
{
|
||||
return Ok(Err(SetError::forbidden()
|
||||
.with_property(MailboxProperty::ShareWith)
|
||||
.with_description(
|
||||
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
|
||||
)));
|
||||
}
|
||||
|
||||
if !changes.acls.is_empty()
|
||||
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user