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:
Generated
+17
@@ -1330,6 +1330,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"idna",
|
||||
"imagesize",
|
||||
"inbuxa-features",
|
||||
"infer 0.22.0",
|
||||
"jmap_proto",
|
||||
"jsonwebtoken",
|
||||
@@ -3955,6 +3956,20 @@ dependencies = [
|
||||
"utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inbuxa-features"
|
||||
version = "0.16.22"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"jmap_proto",
|
||||
"registry",
|
||||
"store",
|
||||
"tokio",
|
||||
"trc",
|
||||
"types",
|
||||
"utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate"
|
||||
version = "0.3.4"
|
||||
@@ -4212,6 +4227,7 @@ dependencies = [
|
||||
"http_proto",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"inbuxa-features",
|
||||
"jmap-tools",
|
||||
"jmap_proto",
|
||||
"mail-auth",
|
||||
@@ -7751,6 +7767,7 @@ dependencies = [
|
||||
"email",
|
||||
"groupware",
|
||||
"hkdf 0.13.0",
|
||||
"inbuxa-features",
|
||||
"jmap-tools",
|
||||
"jmap_proto",
|
||||
"mail-builder 1.0.0",
|
||||
|
||||
@@ -29,6 +29,7 @@ members = [
|
||||
"crates/common",
|
||||
"crates/trc",
|
||||
"crates/migration",
|
||||
"crates/features",
|
||||
"tests",
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ directory = { path = "../directory" }
|
||||
coordinator = { path = "../coordinator" }
|
||||
types = { path = "../types" }
|
||||
registry = { path = "../registry" }
|
||||
inbuxa-features = { path = "../features" }
|
||||
jmap_proto = { path = "../jmap-proto" }
|
||||
sieve-rs = { version = "0.7", features = ["rkyv", "serde"] }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
|
||||
@@ -45,8 +45,7 @@ impl Server {
|
||||
&self,
|
||||
permissions: &structs::Permissions,
|
||||
role_ids: &[Id],
|
||||
// inbuxa: unused until multi-tenancy is rebuilt: the tenant permission ceiling (docs/spec/features/multi-tenancy.md MT-13)
|
||||
_tenant_id: Option<u32>,
|
||||
tenant_id: Option<u32>,
|
||||
) -> trc::Result<PermissionsGroup> {
|
||||
// Calculate effective permissions
|
||||
let (mut permissions, roles) = match permissions {
|
||||
@@ -65,10 +64,46 @@ impl Server {
|
||||
.caused_by(trc::location!())?
|
||||
}
|
||||
|
||||
// inbuxa: MT-13, MT-14, MT-15: cut down to what the tenant allows
|
||||
if let Some(tenant_id) = tenant_id {
|
||||
self.apply_tenant_ceiling(&mut permissions, tenant_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
/// inbuxa: MT-13. The tenant's roles give the base; its own permission
|
||||
/// lists adjust it (`inbuxa_features::tenancy::ceiling`).
|
||||
async fn apply_tenant_ceiling(
|
||||
&self,
|
||||
permissions: &mut PermissionsGroup,
|
||||
tenant_id: u32,
|
||||
) -> trc::Result<()> {
|
||||
use inbuxa_features::tenancy::ceiling::{Policy, ceiling};
|
||||
|
||||
let tenant = self.tenant(tenant_id).await?;
|
||||
let base = self
|
||||
.add_role_permissions(PermissionsGroup::default(), tenant.id_roles.iter().copied())
|
||||
.await?
|
||||
.finalize();
|
||||
let policy = match tenant.permissions.as_deref() {
|
||||
None => Policy::Inherit,
|
||||
Some(list) if list.merge => Policy::Merge {
|
||||
enabled: &list.enabled,
|
||||
disabled: &list.disabled,
|
||||
},
|
||||
Some(list) => Policy::Replace {
|
||||
enabled: &list.enabled,
|
||||
disabled: &list.disabled,
|
||||
},
|
||||
};
|
||||
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn can_set_permissions(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
@@ -225,6 +260,11 @@ impl Default for DefaultPermissions {
|
||||
default.superuser.push(permission);
|
||||
default.tenant.push(permission);
|
||||
}
|
||||
// inbuxa: MT-12: a tenant administrator reads its own tenant
|
||||
Permission::SysTenantGet | Permission::SysTenantQuery => {
|
||||
default.superuser.push(permission);
|
||||
default.tenant.push(permission);
|
||||
}
|
||||
permission => {
|
||||
let name = permission.as_str();
|
||||
if name.starts_with("jmap")
|
||||
|
||||
+25
@@ -280,6 +280,15 @@ impl Server {
|
||||
.registry()
|
||||
.linked_objects(ObjectId::new(ObjectType::Role, role_id.into()))
|
||||
.await?;
|
||||
// inbuxa: MT-16: a role a tenant holds sets its ceiling
|
||||
for tenant_id in inbuxa_features::tenancy::members::tenants_using_role(
|
||||
self.registry(),
|
||||
&linked_objects,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
changes.insert(CacheInvalidation::Tenant(tenant_id));
|
||||
}
|
||||
for linked_object in linked_objects {
|
||||
match linked_object.object() {
|
||||
ObjectType::Account => {
|
||||
@@ -297,6 +306,22 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: MT-16: a tenant's change reaches its people on their next request
|
||||
let tenant_ids = changes
|
||||
.iter()
|
||||
.filter_map(|change| match change {
|
||||
CacheInvalidation::Tenant(tenant_id) => Some(*tenant_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for tenant_id in tenant_ids {
|
||||
for account_id in
|
||||
inbuxa_features::tenancy::members::accounts(self.registry(), tenant_id).await?
|
||||
{
|
||||
changes.insert(CacheInvalidation::AccessToken(account_id));
|
||||
}
|
||||
}
|
||||
|
||||
let changes = changes.into_iter().collect::<Vec<_>>();
|
||||
self.invalidate_local_caches(&changes).await;
|
||||
self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes))
|
||||
|
||||
@@ -31,9 +31,9 @@ impl Server {
|
||||
.add_context(|err| err.caused_by(trc::location!()).account_id(account_id))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
pub async fn get_used_quota_tenant(&self, _tenant_id: u32) -> trc::Result<i64> {
|
||||
Ok(0)
|
||||
// inbuxa: MT-20: storage used by all a tenant's members together
|
||||
pub async fn get_used_quota_tenant(&self, tenant_id: u32) -> trc::Result<i64> {
|
||||
inbuxa_features::tenancy::quota::used(&self.core.storage.data, tenant_id).await
|
||||
}
|
||||
|
||||
pub async fn has_available_quota(
|
||||
@@ -52,6 +52,21 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: MT-19: the tenant's limit applies too, whichever is reached first
|
||||
if let Some(tenant_id) = account.id_tenant {
|
||||
let tenant = self.tenant(tenant_id).await?;
|
||||
if tenant.quota_disk != 0 {
|
||||
let used_quota = self.get_used_quota_tenant(tenant_id).await?.max(0) as u64;
|
||||
|
||||
if used_quota + item_size > tenant.quota_disk {
|
||||
return Err(trc::LimitEvent::TenantQuota
|
||||
.into_err()
|
||||
.ctx(trc::Key::Id, tenant_id)
|
||||
.ctx(trc::Key::Limit, tenant.quota_disk)
|
||||
.ctx(trc::Key::Size, used_quota));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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;
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("a).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()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user