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
@@ -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_protocol_policy; // inbuxa: legacy protocols off
|
||||
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 file_node;
|
||||
pub mod identity;
|
||||
|
||||
@@ -109,6 +109,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::SharingPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::Principal(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ impl Response<'_> {
|
||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self)?
|
||||
}
|
||||
GetRequestMethod::SharingPolicy(request) => {
|
||||
request.resolve_references(self)?
|
||||
}
|
||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||
@@ -158,6 +161,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::SharingPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::AddressBook(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -183,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
|
||||
/// spec, EX-1 to EX-4).
|
||||
#[serde(rename(serialize = "aiExplain"))]
|
||||
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)]
|
||||
|
||||
@@ -77,6 +77,7 @@ pub enum MethodObject {
|
||||
JournalExport,
|
||||
JournalVerification,
|
||||
TenantProtocolPolicy,
|
||||
SharingPolicy,
|
||||
}
|
||||
|
||||
impl MethodObject {
|
||||
@@ -124,6 +125,7 @@ impl MethodObject {
|
||||
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::SharingPolicy => Capability::Inbuxa,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +346,12 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||
"inbuxa:TenantProtocolPolicy/set"
|
||||
}
|
||||
(MethodFunction::Get, MethodObject::SharingPolicy) => {
|
||||
"inbuxa:SharingPolicy/get"
|
||||
}
|
||||
(MethodFunction::Set, MethodObject::SharingPolicy) => {
|
||||
"inbuxa:SharingPolicy/set"
|
||||
}
|
||||
(method, MethodObject::Registry(obj)) => {
|
||||
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:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
|
||||
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
|
||||
|
||||
).or_else(|| {
|
||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||
@@ -578,6 +588,7 @@ impl Display for MethodObject {
|
||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
f.write_str("x:")?;
|
||||
return f.write_str(obj.as_str());
|
||||
|
||||
@@ -134,6 +134,9 @@ pub enum GetRequestMethod {
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
SharingPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -178,6 +181,9 @@ pub enum SetRequestMethod<'x> {
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
SharingPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -213,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
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() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -415,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
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() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
|
||||
@@ -121,6 +121,9 @@ pub enum GetResponseMethod {
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
),
|
||||
SharingPolicy(
|
||||
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -166,6 +169,9 @@ pub enum SetResponseMethod {
|
||||
TenantProtocolPolicy(
|
||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
SharingPolicy(
|
||||
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[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>>
|
||||
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> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||
|
||||
Reference in new issue
Block a user