Multi-tenancy: the inbuxa-features crate, the permission ceiling and tenant disk quota (MT-12, MT-13, MT-14, MT-15, MT-16, MT-19, MT-20)

New crate crates/features (inbuxa-features), AGPL-3.0-only, holding the
tenancy rules. Hooks in common: a tenant's roles and permission lists cap
its people's permissions; a change to a tenant, or to a role a tenant holds,
drops its members' cached permissions; delivery and every other write check
the tenant's maxDiskQuota; usedDiskQuota reads the tenant usage counter.
The default Tenant Administrator role gains sysTenantGet and sysTenantQuery.
This commit is contained in:
2026-09-18 15:19:58 -07:00
parent b6b345a129
commit ad0db8b2b0
18 changed files with 1398 additions and 5 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "inbuxa-features"
description = "INBUXA's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
license = "AGPL-3.0-only"
version = "0.16.22"
edition = "2024"
[dependencies]
registry = { path = "../registry" }
jmap_proto = { path = "../jmap-proto" }
store = { path = "../store" }
trc = { path = "../trc" }
types = { path = "../types" }
utils = { path = "../utils" }
ahash = { version = "0.8.12", features = ["serde"] }
[dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] }
+21
View File
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! INBUXA's rebuilt features.
//!
//! Upstream ships these only in its Enterprise Edition. INBUXA rebuilds each
//! one clean-room from a written spec under `docs/spec/features/`, one module
//! per feature, and ships it to everybody (docs/spec/SPEC.md §2.3, §3).
//!
//! Upstream files change only by small hooks that call in here, each marked
//! with an `inbuxa:` comment naming the requirement it serves. That keeps
//! every upstream merge's conflicts few and predictable.
//!
//! This crate sits below `common`, so hooks anywhere in the server can call
//! it. It works on registry objects and the store directly, never on
//! `common::Server`.
pub mod tenancy;
+178
View File
@@ -0,0 +1,178 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The permission ceiling (MT-13, MT-14, MT-15).
//!
//! A principal in a tenant keeps only those of its permissions the tenant
//! allows. What the tenant allows starts from its roles, is adjusted by its
//! own permission lists, and anything the tenant disables is never allowed.
use trc::ipc::bitset::Bitset;
/// How a tenant's own permission lists adjust the permissions of its roles
/// (MT-14, step 2).
#[derive(Debug, Clone, Copy)]
pub enum Policy<'x, const N: usize> {
/// The roles' permissions, unchanged.
Inherit,
/// The roles' permissions plus `enabled`, less `disabled`.
Merge {
enabled: &'x Bitset<N>,
disabled: &'x Bitset<N>,
},
/// Only `enabled`, less `disabled`. The roles are ignored.
Replace {
enabled: &'x Bitset<N>,
disabled: &'x Bitset<N>,
},
}
/// What a tenant allows its people.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ceiling<const N: usize> {
/// Permissions the tenant allows. A principal's permission outside this
/// set has no effect (MT-13).
pub allowed: Bitset<N>,
/// Permissions the tenant disables. These are removed last, whatever
/// granted them (MT-14, step 3).
pub denied: Bitset<N>,
}
/// Computes a tenant's ceiling (MT-14).
///
/// `base` is the permissions of all the tenant's roles taken together, with
/// each role's own disabled permissions already removed (MT-14, step 1).
pub fn ceiling<const N: usize>(base: Bitset<N>, policy: Policy<'_, N>) -> Ceiling<N> {
match policy {
Policy::Inherit => Ceiling {
allowed: base,
denied: Bitset::new(),
},
Policy::Merge { enabled, disabled } => {
let mut allowed = base;
allowed.union(enabled);
allowed.difference(disabled);
Ceiling {
allowed,
denied: disabled.clone(),
}
}
Policy::Replace { enabled, disabled } => {
let mut allowed = enabled.clone();
allowed.difference(disabled);
Ceiling {
allowed,
denied: disabled.clone(),
}
}
}
}
impl<const N: usize> Ceiling<N> {
/// Cuts a principal's permissions down to the ceiling (MT-13).
///
/// `enabled` and `disabled` are the principal's own, before its disabled
/// permissions are removed. The ceiling only ever removes: a principal
/// never gains a permission from its tenant, so no tenant setting can
/// undo one the server disabled for it (MT-15).
pub fn apply(&self, enabled: &mut Bitset<N>, disabled: &mut Bitset<N>) {
enabled.intersection(&self.allowed);
disabled.union(&self.denied);
}
}
#[cfg(test)]
mod tests {
use super::*;
type Set = Bitset<1>;
fn set(bits: &[usize]) -> Set {
let mut s = Set::new();
for &bit in bits {
s.set(bit);
}
s
}
fn effective(ceiling: &Ceiling<1>, enabled: &[usize], disabled: &[usize]) -> Set {
let mut enabled = set(enabled);
let mut disabled = set(disabled);
ceiling.apply(&mut enabled, &mut disabled);
enabled.difference(&disabled);
enabled
}
#[test]
fn inherit_uses_the_roles() {
let c = ceiling(set(&[1, 2]), Policy::Inherit);
assert_eq!(c.allowed, set(&[1, 2]));
assert!(c.denied.is_empty());
// MT-13: a permission the tenant lacks has no effect, however granted.
assert_eq!(effective(&c, &[1, 3], &[]), set(&[1]));
}
#[test]
fn merge_adds_then_disables() {
let enabled = set(&[3, 4]);
let disabled = set(&[1, 4]);
let c = ceiling(
set(&[1, 2]),
Policy::Merge {
enabled: &enabled,
disabled: &disabled,
},
);
assert_eq!(c.allowed, set(&[2, 3]));
assert_eq!(effective(&c, &[1, 2, 3, 4], &[]), set(&[2, 3]));
}
#[test]
fn replace_ignores_the_roles() {
let enabled = set(&[3]);
let disabled = Set::new();
let c = ceiling(
set(&[1, 2]),
Policy::Replace {
enabled: &enabled,
disabled: &disabled,
},
);
assert_eq!(c.allowed, set(&[3]));
assert_eq!(effective(&c, &[1, 2, 3], &[]), set(&[3]));
}
#[test]
fn disabled_wins_in_replace() {
// Acceptance test 10: enabled and disabled at once is not allowed.
let enabled = set(&[3, 5]);
let disabled = set(&[5]);
let c = ceiling(
Set::new(),
Policy::Replace {
enabled: &enabled,
disabled: &disabled,
},
);
assert!(!c.allowed.get(5usize));
assert_eq!(effective(&c, &[3, 5], &[]), set(&[3]));
}
#[test]
fn server_disabled_stays_disabled() {
// MT-15: the principal's own disabled permission survives any ceiling.
let enabled = set(&[1, 2]);
let disabled = Set::new();
let c = ceiling(
Set::new(),
Policy::Merge {
enabled: &enabled,
disabled: &disabled,
},
);
assert_eq!(effective(&c, &[1, 2], &[2]), set(&[1]));
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Moving a domain into or out of a tenant (MT-8, MT-17).
//!
//! A domain moves into a tenant only from no tenant, and out of one only back
//! to no tenant. Moving in, its principals (accounts, groups, mailing lists)
//! and DKIM keys move with it, after the tenant's limits are checked. Moving
//! out is refused while any principal on it is in the tenant; with none, its
//! DKIM keys move out with it. A principal on it in a third tenant blocks
//! either move, and so does any link the move would carry across a tenant
//! boundary (MT-3).
use crate::tenancy::{
links,
quota::{self, LimitReached},
};
use ahash::{AHashMap, AHashSet};
use registry::{
schema::{
enums::TenantStorageQuota,
prelude::{OBJ_FILTER_TENANT, Object, ObjectInner, ObjectType},
structs::{Account, DkimSignature},
},
types::id::ObjectId,
};
use store::{
RegistryStore,
registry::write::{RegistryWrite, RegistryWriteResult},
};
use types::id::Id;
/// Why a domain can't move.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
/// Straight from one tenant to another.
Across,
/// Principals on the domain belong somewhere other than the destination.
PrincipalsRemain { example: ObjectId, count: usize },
/// The move would leave this object linked across a tenant boundary.
ForeignLink(ObjectId),
/// The destination tenant's limit would be crossed.
Limit(LimitReached),
}
/// A move that passed every check, ready to apply once the domain itself is
/// saved.
#[derive(Debug, Clone, Default)]
pub struct Move {
/// The domain's new tenant.
pub to: Option<Id>,
/// The principals and keys that move with the domain.
pub carried: Vec<ObjectId>,
}
/// Whether a type moves with its domain.
fn moves_with_domain(object_type: ObjectType) -> bool {
matches!(
object_type,
ObjectType::Account | ObjectType::MailingList | ObjectType::DkimSignature
)
}
/// Checks a domain's move from `from` to `to` (MT-8), counting what moves
/// with it against the destination's limits (MT-17). `limit` gives the
/// destination's limit for each kind, `None` for no limit.
pub async fn plan(
registry: &RegistryStore,
domain_id: Id,
from: Option<Id>,
to: Option<Id>,
limit: impl Fn(TenantStorageQuota) -> Option<u64>,
) -> trc::Result<Result<Move, Refusal>> {
let domain = ObjectId::new(ObjectType::Domain, domain_id);
if from == to {
return Ok(Ok(Move {
to,
carried: vec![],
}));
}
if from.is_some() && to.is_some() {
return Ok(Err(Refusal::Across));
}
// Sort out what links to the domain
let mut carried = Vec::new();
let mut adding: AHashMap<TenantStorageQuota, u64> = AHashMap::new();
let mut blocking = Vec::new();
let mut seen = AHashSet::new();
for referrer in registry.linked_objects(domain).await? {
let object_type = referrer.object();
// An account links to its domain once more for each alias on it
if object_type.flags() & OBJ_FILTER_TENANT == 0 || !seen.insert(referrer) {
continue;
}
let Some(object) = registry.get(referrer).await? else {
continue;
};
let tenant = object.inner.member_tenant_id();
if tenant == to {
continue;
}
if moves_with_domain(object_type) && tenant == from {
let is_principal = object_type != ObjectType::DkimSignature;
if to.is_none() && is_principal {
// Moving out: the tenant's people stay, so the domain can't go
blocking.push(referrer);
} else {
let kind = match &object.inner {
ObjectInner::Account(Account::Group(_)) => Some(1),
_ => None,
};
if let Some(quota) = quota::limit_for_id(referrer, kind) {
*adding.entry(quota).or_default() += 1;
}
carried.push(referrer);
}
} else if moves_with_domain(object_type) && object_type != ObjectType::DkimSignature {
// A principal in a third tenant
blocking.push(referrer);
} else {
return Ok(Err(Refusal::ForeignLink(referrer)));
}
}
if let Some(example) = blocking.first() {
return Ok(Err(Refusal::PrincipalsRemain {
example: *example,
count: blocking.len(),
}));
}
// Nothing may be left linked across the boundary
let moving = carried
.iter()
.copied()
.chain([domain])
.collect::<AHashSet<_>>();
for object_id in &carried {
if let Some(object) = registry.get(*object_id).await?
&& let Some(foreign) = links::foreign_link(registry, &object, to, None, &moving).await?
{
return Ok(Err(Refusal::ForeignLink(foreign)));
}
if let Some(foreign) = links::foreign_referrer(registry, *object_id, to, &moving).await? {
return Ok(Err(Refusal::ForeignLink(foreign)));
}
}
// The destination's limits, the domain itself included
if let Some(tenant_id) = to {
*adding.entry(TenantStorageQuota::MaxDomains).or_default() += 1;
let mut quotas = adding.into_iter().collect::<Vec<_>>();
quotas.sort_unstable_by_key(|(quota, _)| *quota as u16);
for (quota, count) in quotas {
if let Err(reached) = quota::check(
registry,
tenant_id.document_id(),
quota,
limit(quota),
count,
)
.await?
{
return Ok(Err(Refusal::Limit(reached)));
}
}
}
Ok(Ok(Move { to, carried }))
}
/// Moves what travels with a domain into its new tenant, once the domain is
/// saved. Returns each object changed, as it was and as it is now, for cache
/// invalidation. An object that changed or vanished since `plan` is skipped.
pub async fn apply(
registry: &RegistryStore,
planned: &Move,
) -> trc::Result<Vec<(Id, Object, Object)>> {
let mut changed = Vec::with_capacity(planned.carried.len());
for object_id in &planned.carried {
let Some(old) = registry.get(*object_id).await? else {
continue;
};
let mut new = old.clone();
set_tenant(&mut new.inner, planned.to);
if new.inner == old.inner {
continue;
}
if let RegistryWriteResult::Success(_) = registry
.write(RegistryWrite::update(object_id.id(), &new, &old))
.await?
{
changed.push((object_id.id(), old, new));
}
}
Ok(changed)
}
/// Sets or clears the tenant of an object that moves with its domain.
fn set_tenant(object: &mut ObjectInner, tenant: Option<Id>) {
match object {
ObjectInner::Account(Account::User(obj)) => obj.member_tenant_id = tenant,
ObjectInner::Account(Account::Group(obj)) => obj.member_tenant_id = tenant,
ObjectInner::MailingList(obj) => obj.member_tenant_id = tenant,
ObjectInner::DkimSignature(DkimSignature::Dkim1Ed25519Sha256(obj)) => {
obj.member_tenant_id = tenant
}
ObjectInner::DkimSignature(DkimSignature::Dkim1RsaSha256(obj)) => {
obj.member_tenant_id = tenant
}
ObjectInner::DkimSignature(DkimSignature::Dkim2Ed25519Sha256(obj)) => {
obj.member_tenant_id = tenant
}
ObjectInner::DkimSignature(DkimSignature::Dkim2RsaSha256(obj)) => {
obj.member_tenant_id = tenant
}
_ => {}
}
}
+112
View File
@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Links between objects never cross a tenant boundary (MT-3).
//!
//! The registry store already refuses a link from an object in a tenant to
//! one outside it. This covers the other direction, a link from an object in
//! no tenant to one inside a tenant, which MT-3 counts as a different tenant
//! too. It also re-checks every link, not only new ones, when an object's
//! tenant changes, since a move can strand links that were fine before.
use ahash::AHashSet;
use registry::{
schema::prelude::{OBJ_FILTER_TENANT, Object, ObjectType},
types::{
id::ObjectId,
index::{IndexBuilder, IndexKey},
},
};
use store::RegistryStore;
use types::id::Id;
/// Whether an object of this type can hold links that MT-3 governs: every
/// type that can belong to a tenant, plus the two server-level objects that
/// name roles for tenants.
fn is_governed(object_type: ObjectType) -> bool {
object_type.flags() & OBJ_FILTER_TENANT != 0
|| matches!(object_type, ObjectType::Tenant | ObjectType::Authentication)
}
/// The objects this object links to that can belong to a tenant.
pub fn tenant_links(object: &Object) -> Vec<ObjectId> {
let mut index = IndexBuilder::default();
object.index(&mut index);
index
.keys
.iter()
.filter_map(|key| match key {
IndexKey::ForeignKey { object_id, .. }
if object_id.object().flags() & OBJ_FILTER_TENANT != 0 =>
{
Some(*object_id)
}
_ => None,
})
.collect()
}
/// Finds a link from `object` to an object in a different tenant, "no
/// tenant" included (MT-3), and returns the object it can't link to.
///
/// `tenant` is the tenant `object` will belong to. On an update, `old` is the
/// object as stored: links it already had are checked again only if the
/// tenant changes. Links to objects in `moving` are skipped, because those
/// objects are moving into `tenant` together with this one (MT-8).
pub async fn foreign_link(
registry: &RegistryStore,
object: &Object,
tenant: Option<Id>,
old: Option<&Object>,
moving: &AHashSet<ObjectId>,
) -> trc::Result<Option<ObjectId>> {
if !is_governed(object.object_type()) {
return Ok(None);
}
let existing = match old {
Some(old) if old.inner.member_tenant_id() == tenant => tenant_links(old),
_ => Vec::new(),
};
for target in tenant_links(object) {
if existing.contains(&target) || moving.contains(&target) {
continue;
}
// A missing target is left to the store, which names it as an
// invalid foreign key in the same way.
if let Some(linked) = registry.get(target).await?
&& linked.inner.member_tenant_id() != tenant
{
return Ok(Some(target));
}
}
Ok(None)
}
/// Finds an object outside `moving` that links to `object_id` from a tenant
/// other than `tenant` (MT-3, MT-8). Used before `object_id` moves into
/// `tenant`, since the link would then cross a tenant boundary.
pub async fn foreign_referrer(
registry: &RegistryStore,
object_id: ObjectId,
tenant: Option<Id>,
moving: &AHashSet<ObjectId>,
) -> trc::Result<Option<ObjectId>> {
for referrer in registry.linked_objects(object_id).await? {
if moving.contains(&referrer) || !is_governed(referrer.object()) {
continue;
}
if let Some(object) = registry.get(referrer).await?
&& object.inner.member_tenant_id() != tenant
{
return Ok(Some(referrer));
}
}
Ok(None)
}
+56
View File
@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The logo that applies to a signed-in principal (MT-22).
//!
//! Its domain's logo if set, else its tenant's. The value is returned as
//! stored, a URL or a data URL: the server never fetches a logo URL itself
//! (MT-23). Branding extends the chain past the tenant (BT-1).
use registry::schema::structs::{Account, Domain, Tenant};
use store::RegistryStore;
use types::id::Id;
/// The logo that applies to an account, read from the registry.
pub async fn for_account(registry: &RegistryStore, account_id: u32) -> trc::Result<Option<String>> {
let Some(account) = registry.object::<Account>(Id::from(account_id)).await? else {
return Ok(None);
};
let (domain_id, tenant_id) = match &account {
Account::User(obj) => (obj.domain_id, obj.member_tenant_id),
Account::Group(obj) => (obj.domain_id, obj.member_tenant_id),
};
let domain = registry.object::<Domain>(domain_id).await?;
let tenant = match tenant_id {
Some(tenant_id) => registry.object::<Tenant>(tenant_id).await?,
None => None,
};
Ok(applicable(
domain.as_ref().and_then(|d| d.logo.as_deref()),
tenant.as_ref().and_then(|t| t.logo.as_deref()),
)
.map(str::to_string))
}
/// The logo that applies, from the principal's domain's and tenant's logos.
pub fn applicable<'x>(domain: Option<&'x str>, tenant: Option<&'x str>) -> Option<&'x str> {
domain
.filter(|logo| !logo.is_empty())
.or(tenant.filter(|logo| !logo.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_then_tenant() {
assert_eq!(applicable(Some("d"), Some("t")), Some("d"));
assert_eq!(applicable(None, Some("t")), Some("t"));
assert_eq!(applicable(Some(""), Some("t")), Some("t"));
assert_eq!(applicable(None, None), None);
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Who a change to a tenant affects (MT-16).
//!
//! A principal's permissions are cached. A change to its tenant's roles,
//! permissions or quotas has to reach it on its next request, so the cached
//! permissions of everyone in the tenant are dropped with the change.
use registry::{
schema::{
prelude::ObjectType,
structs::{Roles, Tenant},
},
types::id::ObjectId,
};
use store::{RegistryStore, registry::RegistryQuery};
use trc::AddContext;
use types::id::Id;
/// The accounts that belong to a tenant.
pub async fn accounts(registry: &RegistryStore, tenant_id: u32) -> trc::Result<Vec<u32>> {
registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account).with_tenant(Some(tenant_id)))
.await
.map(|ids| ids.into_iter().map(|id| id.document_id()).collect())
.caused_by(trc::location!())
}
/// The tenants whose ceiling a role takes part in, given the objects that
/// link to the role. A tenant names the role itself when its roles are
/// `Custom`; `Authentication` names it for every tenant whose roles are
/// `Default`.
pub async fn tenants_using_role(
registry: &RegistryStore,
linked: &[ObjectId],
) -> trc::Result<Vec<u32>> {
let mut tenants = Vec::new();
let mut by_default = false;
for object_id in linked {
match object_id.object() {
ObjectType::Tenant => tenants.push(object_id.id().document_id()),
ObjectType::Authentication => by_default = true,
_ => {}
}
}
if by_default {
for id in registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Tenant))
.await
.caused_by(trc::location!())?
{
if let Some(tenant) = registry.object::<Tenant>(id).await?
&& matches!(tenant.roles, Roles::Default)
&& !tenants.contains(&id.document_id())
{
tenants.push(id.document_id());
}
}
}
Ok(tenants)
}
+22
View File
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Multi-tenancy, built from `docs/spec/features/multi-tenancy.md`.
//!
//! A tenant is a separate organization on one server. Its people reach only
//! its own objects, hold at most the permissions it allows, and create only
//! as much as its limits let them. The requirement each piece serves is named
//! as `MT-n`, after the spec.
pub mod ceiling;
pub mod domain_move;
pub mod links;
pub mod logo;
pub mod members;
pub mod queue;
pub mod quota;
pub mod reach;
pub mod writes;
+95
View File
@@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! What a tenant administrator sees of the mail queue (MT-5).
//!
//! It sees a queued message when any recipient is on one of the tenant's
//! domains, whoever sent it. It also sees a message one of its own people
//! sent, until the message leaves the queue: an authenticated sender whose
//! return path is on the tenant's domains. Nothing else in the queue is
//! visible to it.
use ahash::AHashSet;
use registry::schema::{prelude::ObjectType, structs::Domain};
use store::{RegistryStore, registry::RegistryQuery};
use trc::AddContext;
use types::id::Id;
/// The names a tenant's domains answer to, lowercased: each domain's name
/// and its aliases.
pub async fn tenant_domains(
registry: &RegistryStore,
tenant_id: u32,
) -> trc::Result<AHashSet<String>> {
let mut names = AHashSet::new();
for id in registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain).with_tenant(Some(tenant_id)))
.await
.caused_by(trc::location!())?
{
if let Some(domain) = registry.object::<Domain>(id).await? {
names.insert(domain.name.to_lowercase());
for alias in domain.aliases.iter() {
names.insert(alias.to_lowercase());
}
}
}
Ok(names)
}
fn domain_of(address: &str) -> Option<String> {
address
.rsplit_once('@')
.map(|(_, domain)| domain.to_lowercase())
}
/// Whether a tenant with these domains sees a queued message (MT-5).
pub fn sees<'x>(
domains: &AHashSet<String>,
recipients: impl IntoIterator<Item = &'x str>,
return_path: &str,
from_authenticated: bool,
) -> bool {
recipients
.into_iter()
.any(|rcpt| domain_of(rcpt).is_some_and(|d| domains.contains(&d)))
|| (from_authenticated && domain_of(return_path).is_some_and(|d| domains.contains(&d)))
}
#[cfg(test)]
mod tests {
use super::*;
fn domains() -> AHashSet<String> {
["t.example".to_string()].into_iter().collect()
}
#[test]
fn addressed_to_the_tenant() {
// Observed 4: mail to the tenant's domain from anyone.
assert!(sees(&domains(), ["[email protected]"], "[email protected]", false));
assert!(sees(
&domains(),
["[email protected]", "[email protected]"],
"[email protected]",
true
));
}
#[test]
fn sent_by_the_tenant() {
assert!(sees(&domains(), ["[email protected]"], "[email protected]", true));
// A return path on the tenant's domain from an unauthenticated sender
// is anyone's claim, not the tenant's own mail.
assert!(!sees(&domains(), ["[email protected]"], "[email protected]", false));
}
#[test]
fn nothing_else() {
assert!(!sees(&domains(), ["[email protected]"], "[email protected]", true));
assert!(!sees(&domains(), ["[email protected]"], "<>", true));
}
}
+221
View File
@@ -0,0 +1,221 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Tenant limits: how many of each kind of object a tenant may hold
//! (MT-17, MT-18) and how much storage its members use (MT-20, MT-21).
use registry::{
schema::{
enums::TenantStorageQuota,
prelude::{ObjectInner, ObjectType, Property},
structs::Account,
},
types::id::ObjectId,
};
use store::{
RegistryStore, Store, ValueKey,
registry::RegistryQuery,
write::{BatchBuilder, ValueClass},
};
use trc::AddContext;
use types::id::Id;
/// The count limit that applies to an object, if any (MT-17).
pub fn limit_for(object: &ObjectInner) -> Option<TenantStorageQuota> {
Some(match object {
ObjectInner::Account(Account::User(_)) => TenantStorageQuota::MaxAccounts,
ObjectInner::Account(Account::Group(_)) => TenantStorageQuota::MaxGroups,
ObjectInner::Domain(_) => TenantStorageQuota::MaxDomains,
ObjectInner::MailingList(_) => TenantStorageQuota::MaxMailingLists,
ObjectInner::Role(_) => TenantStorageQuota::MaxRoles,
ObjectInner::OAuthClient(_) => TenantStorageQuota::MaxOauthClients,
ObjectInner::DkimSignature(_) => TenantStorageQuota::MaxDkimKeys,
ObjectInner::DnsServer(_) => TenantStorageQuota::MaxDnsServers,
ObjectInner::Directory(_) => TenantStorageQuota::MaxDirectories,
ObjectInner::AcmeProvider(_) => TenantStorageQuota::MaxAcmeProviders,
_ => return None,
})
}
/// The object type a count limit counts, and for accounts, which kind.
fn counted(quota: TenantStorageQuota) -> Option<(ObjectType, Option<u16>)> {
Some(match quota {
TenantStorageQuota::MaxAccounts => (ObjectType::Account, Some(0)),
TenantStorageQuota::MaxGroups => (ObjectType::Account, Some(1)),
TenantStorageQuota::MaxDomains => (ObjectType::Domain, None),
TenantStorageQuota::MaxMailingLists => (ObjectType::MailingList, None),
TenantStorageQuota::MaxRoles => (ObjectType::Role, None),
TenantStorageQuota::MaxOauthClients => (ObjectType::OAuthClient, None),
TenantStorageQuota::MaxDkimKeys => (ObjectType::DkimSignature, None),
TenantStorageQuota::MaxDnsServers => (ObjectType::DnsServer, None),
TenantStorageQuota::MaxDirectories => (ObjectType::Directory, None),
TenantStorageQuota::MaxAcmeProviders => (ObjectType::AcmeProvider, None),
TenantStorageQuota::MaxDiskQuota => return None,
})
}
/// Which count limit an object of this type and kind counts against. The
/// inverse of `counted`, for objects known only by id.
pub fn limit_for_id(object_id: ObjectId, account_kind: Option<u16>) -> Option<TenantStorageQuota> {
Some(match object_id.object() {
ObjectType::Account => match account_kind {
Some(1) => TenantStorageQuota::MaxGroups,
_ => TenantStorageQuota::MaxAccounts,
},
ObjectType::Domain => TenantStorageQuota::MaxDomains,
ObjectType::MailingList => TenantStorageQuota::MaxMailingLists,
ObjectType::Role => TenantStorageQuota::MaxRoles,
ObjectType::OAuthClient => TenantStorageQuota::MaxOauthClients,
ObjectType::DkimSignature => TenantStorageQuota::MaxDkimKeys,
ObjectType::DnsServer => TenantStorageQuota::MaxDnsServers,
ObjectType::Directory => TenantStorageQuota::MaxDirectories,
ObjectType::AcmeProvider => TenantStorageQuota::MaxAcmeProviders,
_ => return None,
})
}
/// How many objects a tenant holds that count against `quota`.
pub async fn count(
registry: &RegistryStore,
tenant_id: u32,
quota: TenantStorageQuota,
) -> trc::Result<u64> {
let Some((object_type, kind)) = counted(quota) else {
return Ok(0);
};
let mut query = RegistryQuery::new(object_type).with_tenant(Some(tenant_id));
if let Some(kind) = kind {
query = query.equal(Property::Type, kind);
}
registry
.query::<Vec<Id>>(query)
.await
.map(|ids| ids.len() as u64)
.caused_by(trc::location!())
}
/// A count limit that adding objects would cross.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LimitReached {
pub quota: TenantStorageQuota,
pub limit: u64,
pub count: u64,
}
/// Checks that a tenant can take `adding` more objects that count against
/// `quota`, given its `limit` (`None` is no limit). Objects already over a
/// lowered limit stay: only new ones are refused (MT-18).
pub async fn check(
registry: &RegistryStore,
tenant_id: u32,
quota: TenantStorageQuota,
limit: Option<u64>,
adding: u64,
) -> trc::Result<Result<(), LimitReached>> {
let Some(limit) = limit else {
return Ok(Ok(()));
};
let count = count(registry, tenant_id, quota).await?;
if count + adding > limit {
Ok(Err(LimitReached {
quota,
limit,
count,
}))
} else {
Ok(Ok(()))
}
}
/// The key of a tenant's storage usage counter.
fn usage_key(tenant_id: u32) -> ValueKey<ValueClass> {
ValueKey::from(ValueClass::TenantQuota(tenant_id))
}
/// Storage used by all a tenant's members together, in bytes (MT-20).
pub async fn used(data: &Store, tenant_id: u32) -> trc::Result<i64> {
data.get_counter(usage_key(tenant_id))
.await
.caused_by(trc::location!())
}
/// Recomputes a tenant's storage usage from its members' own usage
/// (MT-21). Safe on a live server: the stored figure is corrected by the
/// difference, so deliveries counted while this runs aren't lost. One that
/// lands between reading the members and reading the total can leave the
/// figure off by that message until the next run.
pub async fn recalculate(
data: &Store,
registry: &RegistryStore,
tenant_id: u32,
) -> trc::Result<i64> {
let members = registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account).with_tenant(Some(tenant_id)))
.await
.caused_by(trc::location!())?;
let mut total = 0i64;
for member in members {
total += data
.get_counter(ValueKey {
account_id: member.document_id(),
collection: 0,
document_id: 0,
class: ValueClass::Quota,
})
.await
.caused_by(trc::location!())?
.max(0);
}
let stored = used(data, tenant_id).await?;
if stored != total {
let mut batch = BatchBuilder::new();
batch.add(ValueClass::TenantQuota(tenant_id), total - stored);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
}
Ok(total)
}
/// Every tenant's id, for recomputing all of them (MT-21,
/// `resetTenantQuotas`).
pub async fn all_tenants(registry: &RegistryStore) -> trc::Result<Vec<u32>> {
registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Tenant))
.await
.map(|ids| ids.into_iter().map(|id| id.document_id()).collect())
.caused_by(trc::location!())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_count_limit_counts_something() {
for quota in [
TenantStorageQuota::MaxAccounts,
TenantStorageQuota::MaxGroups,
TenantStorageQuota::MaxDomains,
TenantStorageQuota::MaxMailingLists,
TenantStorageQuota::MaxRoles,
TenantStorageQuota::MaxOauthClients,
TenantStorageQuota::MaxDkimKeys,
TenantStorageQuota::MaxDnsServers,
TenantStorageQuota::MaxDirectories,
TenantStorageQuota::MaxAcmeProviders,
] {
let (object_type, kind) = counted(quota).unwrap();
let id = ObjectId::new(object_type, Id::new(1));
assert_eq!(limit_for_id(id, kind), Some(quota));
}
assert!(counted(TenantStorageQuota::MaxDiskQuota).is_none());
}
}
+80
View File
@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Which registry object types a principal in a tenant can reach (MT-2,
//! MT-11, MT-12).
//!
//! Inside a tenant, a principal reaches the object types that can belong to
//! a tenant (filtered to its own), its own account's settings and
//! credentials, the queue (filtered by `queue`), and, to read only, its own
//! tenant. Everything else is server-level and refused, whatever permissions
//! it holds.
use registry::schema::prelude::{OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, ObjectType};
/// Whether a principal in a tenant can read objects of this type.
pub fn can_read(object_type: ObjectType) -> bool {
object_type.flags() & (OBJ_FILTER_TENANT | OBJ_FILTER_ACCOUNT) != 0
|| matches!(
object_type,
ObjectType::AccountSettings
| ObjectType::AccountPassword
| ObjectType::AppPassword
| ObjectType::ApiKey
| ObjectType::QueuedMessage
| ObjectType::Tenant
)
}
/// Whether a principal in a tenant can create, change or destroy objects of
/// this type. The tenant object itself is server-level (MT-12).
pub fn can_write(object_type: ObjectType) -> bool {
can_read(object_type) && object_type != ObjectType::Tenant
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tenant_types() {
for t in [
ObjectType::Account,
ObjectType::Domain,
ObjectType::DkimSignature,
ObjectType::AcmeProvider,
ObjectType::DnsServer,
ObjectType::Role,
ObjectType::MailingList,
ObjectType::OAuthClient,
ObjectType::Directory,
ObjectType::QueuedMessage,
] {
assert!(can_read(t) && can_write(t), "{t:?}");
}
}
#[test]
fn own_tenant_is_read_only() {
assert!(can_read(ObjectType::Tenant));
assert!(!can_write(ObjectType::Tenant));
}
#[test]
fn server_level_types() {
// Observed 3: listeners, certificates and system settings.
for t in [
ObjectType::NetworkListener,
ObjectType::Certificate,
ObjectType::SystemSettings,
ObjectType::Authentication,
ObjectType::Bootstrap,
ObjectType::Task,
] {
assert!(!can_read(t), "{t:?}");
}
}
}
+200
View File
@@ -0,0 +1,200 @@
/*
* SPDX-FileCopyrightText: 2026 John Coffey
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Tenancy checks on a registry write over JMAP, before it's saved.
//!
//! - MT-3: no link crosses a tenant boundary.
//! - MT-7: a principal created without a tenant takes its domain's.
//! - MT-8: a domain moves only through no tenant, taking its principals and
//! keys in with it, and only when its people let it out.
//! - MT-17: creating an object, or moving a domain in, stays within the
//! tenant's count limits, and the server emits `limit.tenant-quota` when
//! it doesn't.
use crate::tenancy::{
domain_move::{self, Move, Refusal},
links,
quota::{self, LimitReached},
};
use ahash::AHashSet;
use jmap_proto::error::set::{SetError, SetErrorType};
use registry::{
schema::{
enums::TenantStorageQuota,
prelude::{Object, ObjectInner, Property},
structs::{Account, Domain, Tenant},
},
types::{EnumImpl, id::ObjectId},
};
use store::RegistryStore;
use types::id::Id;
/// What a checked write still has to do once it's saved.
#[derive(Debug, Default)]
pub struct AfterSave {
/// A domain's move, whose principals and keys follow it (MT-8).
pub domain_move: Option<Move>,
}
/// The domain a principal lives on.
fn principal_domain(object: &ObjectInner) -> Option<Id> {
match object {
ObjectInner::Account(Account::User(obj)) => Some(obj.domain_id),
ObjectInner::Account(Account::Group(obj)) => Some(obj.domain_id),
ObjectInner::MailingList(obj) => Some(obj.domain_id),
_ => None,
}
}
/// MT-7: a principal created without a tenant takes its domain's. Called
/// only for a server-level writer, since one in a tenant always creates in
/// its own.
pub async fn default_tenant(registry: &RegistryStore, object: &mut Object) -> trc::Result<()> {
if object.inner.member_tenant_id().is_none()
&& let Some(domain_id) = principal_domain(&object.inner)
&& let Some(domain) = registry.object::<Domain>(domain_id).await?
&& let Some(tenant_id) = domain.member_tenant_id
{
object.inner.set_member_tenant_id(tenant_id);
}
Ok(())
}
/// A tenant's count limit, `None` when it has none.
async fn limits(
registry: &RegistryStore,
tenant_id: Id,
) -> trc::Result<impl Fn(TenantStorageQuota) -> Option<u64>> {
let quotas = registry
.object::<Tenant>(tenant_id)
.await?
.map(|tenant| tenant.quotas)
.unwrap_or_default();
Ok(move |quota: TenantStorageQuota| quotas.get(&quota).copied())
}
/// Runs the tenancy checks on a write. `id` and `old` are the stored object
/// on an update, `None` on a create.
pub async fn check(
registry: &RegistryStore,
id: Option<Id>,
old: Option<&Object>,
new: &Object,
) -> trc::Result<Result<AfterSave, SetError<Property>>> {
let tenant = new.inner.member_tenant_id();
let mut after = AfterSave::default();
// MT-8: a domain changing tenant
if let (Some(id), Some(old), ObjectInner::Domain(_)) = (id, old, &new.inner)
&& old.inner.member_tenant_id() != tenant
{
let limit = match tenant {
Some(tenant_id) => Some(limits(registry, tenant_id).await?),
None => None,
};
let planned = domain_move::plan(
registry,
id,
old.inner.member_tenant_id(),
tenant,
|quota| limit.as_ref().and_then(|limit| limit(quota)),
)
.await?;
match planned {
Ok(planned) => after.domain_move = Some(planned),
Err(refusal) => return Ok(Err(refused_move(refusal, tenant))),
}
}
// MT-3: no link across a tenant boundary
if let Some(foreign) = links::foreign_link(registry, new, tenant, old, &AHashSet::new()).await?
{
return Ok(Err(foreign_key(foreign)));
}
// MT-17: count limits on a new object
if old.is_none()
&& let Some(tenant_id) = tenant
&& let Some(quota) = quota::limit_for(&new.inner)
{
let limit = limits(registry, tenant_id).await?;
if let Err(reached) =
quota::check(registry, tenant_id.document_id(), quota, limit(quota), 1).await?
{
return Ok(Err(over_quota(reached, tenant_id)));
}
}
Ok(Ok(after))
}
/// Finishes a checked write once it's saved. Returns each other object it
/// changed, as it was and as it is now, for cache invalidation.
pub async fn after_save(
data: &store::Store,
registry: &RegistryStore,
after: AfterSave,
) -> trc::Result<Vec<(Id, Object, Object)>> {
let Some(planned) = after.domain_move else {
return Ok(vec![]);
};
let changed = domain_move::apply(registry, &planned).await?;
// MT-20: the members who moved in bring their usage with them
if let Some(tenant_id) = planned.to
&& !changed.is_empty()
{
quota::recalculate(data, registry, tenant_id.document_id()).await?;
}
Ok(changed)
}
fn foreign_key(object_id: ObjectId) -> SetError<Property> {
SetError::new(SetErrorType::InvalidForeignKey)
.with_object_id(object_id)
.with_description(format!(
"{} {} belongs to a different tenant.",
object_id.object().as_str(),
object_id.id()
))
}
/// Refuses with `overQuota`, naming the limit, and emits
/// `limit.tenant-quota` (MT-17).
fn over_quota(reached: LimitReached, tenant_id: Id) -> SetError<Property> {
let name = reached.quota.as_str();
trc::event!(
Limit(trc::LimitEvent::TenantQuota),
Id = tenant_id.document_id(),
Limit = reached.limit,
Total = reached.count,
Details = name,
);
SetError::new(SetErrorType::OverQuota).with_description(format!(
"The tenant's {name} limit of {} is reached.",
reached.limit
))
}
fn refused_move(refusal: Refusal, tenant: Option<Id>) -> SetError<Property> {
match refusal {
Refusal::Across => SetError::invalid_properties()
.with_property(Property::MemberTenantId)
.with_description(
"A domain moves between tenants only by leaving one for no tenant first.",
),
Refusal::PrincipalsRemain { example, count } => SetError::invalid_properties()
.with_property(Property::MemberTenantId)
.with_object_id(example)
.with_description(format!(
"{count} principal(s) on this domain belong to a tenant it would leave."
)),
Refusal::ForeignLink(object_id) => foreign_key(object_id),
Refusal::Limit(reached) => over_quota(reached, tenant.unwrap_or_default()),
}
}