diff --git a/Cargo.lock b/Cargo.lock index 934972d..e087148 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 20b326e..55c2b2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/common", "crates/trc", "crates/migration", + "crates/features", "tests", ] diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index c584f85..5e28be2 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -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"] } diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index 2497959..eac86ef 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -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, + tenant_id: Option, ) -> trc::Result { // 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") diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index d4fc8c2..94772fd 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -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::>(); + 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::>(); self.invalidate_local_caches(&changes).await; self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes)) diff --git a/crates/common/src/storage/quota.rs b/crates/common/src/storage/quota.rs index b106f23..35fba54 100644 --- a/crates/common/src/storage/quota.rs +++ b/crates/common/src/storage/quota.rs @@ -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 { - 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 { + 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(()) } diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml new file mode 100644 index 0000000..27f1ca7 --- /dev/null +++ b/crates/features/Cargo.toml @@ -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"] } diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs new file mode 100644 index 0000000..db1bbd9 --- /dev/null +++ b/crates/features/src/lib.rs @@ -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; diff --git a/crates/features/src/tenancy/ceiling.rs b/crates/features/src/tenancy/ceiling.rs new file mode 100644 index 0000000..fbf98e6 --- /dev/null +++ b/crates/features/src/tenancy/ceiling.rs @@ -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, + disabled: &'x Bitset, + }, + /// Only `enabled`, less `disabled`. The roles are ignored. + Replace { + enabled: &'x Bitset, + disabled: &'x Bitset, + }, +} + +/// What a tenant allows its people. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ceiling { + /// Permissions the tenant allows. A principal's permission outside this + /// set has no effect (MT-13). + pub allowed: Bitset, + /// Permissions the tenant disables. These are removed last, whatever + /// granted them (MT-14, step 3). + pub denied: Bitset, +} + +/// 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(base: Bitset, policy: Policy<'_, N>) -> Ceiling { + 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 Ceiling { + /// 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, disabled: &mut Bitset) { + 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])); + } +} diff --git a/crates/features/src/tenancy/domain_move.rs b/crates/features/src/tenancy/domain_move.rs new file mode 100644 index 0000000..5ba7732 --- /dev/null +++ b/crates/features/src/tenancy/domain_move.rs @@ -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, + /// The principals and keys that move with the domain. + pub carried: Vec, +} + +/// 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, + to: Option, + limit: impl Fn(TenantStorageQuota) -> Option, +) -> trc::Result> { + 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 = 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::>(); + 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::>(); + 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> { + 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) { + 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 + } + _ => {} + } +} diff --git a/crates/features/src/tenancy/links.rs b/crates/features/src/tenancy/links.rs new file mode 100644 index 0000000..a17944a --- /dev/null +++ b/crates/features/src/tenancy/links.rs @@ -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 { + 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, + old: Option<&Object>, + moving: &AHashSet, +) -> trc::Result> { + 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, + moving: &AHashSet, +) -> trc::Result> { + 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) +} diff --git a/crates/features/src/tenancy/logo.rs b/crates/features/src/tenancy/logo.rs new file mode 100644 index 0000000..26c6d2e --- /dev/null +++ b/crates/features/src/tenancy/logo.rs @@ -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> { + let Some(account) = registry.object::(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_id).await?; + let tenant = match tenant_id { + Some(tenant_id) => registry.object::(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); + } +} diff --git a/crates/features/src/tenancy/members.rs b/crates/features/src/tenancy/members.rs new file mode 100644 index 0000000..e6c83a8 --- /dev/null +++ b/crates/features/src/tenancy/members.rs @@ -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> { + registry + .query::>(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> { + 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::>(RegistryQuery::new(ObjectType::Tenant)) + .await + .caused_by(trc::location!())? + { + if let Some(tenant) = registry.object::(id).await? + && matches!(tenant.roles, Roles::Default) + && !tenants.contains(&id.document_id()) + { + tenants.push(id.document_id()); + } + } + } + + Ok(tenants) +} diff --git a/crates/features/src/tenancy/mod.rs b/crates/features/src/tenancy/mod.rs new file mode 100644 index 0000000..16e2431 --- /dev/null +++ b/crates/features/src/tenancy/mod.rs @@ -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; diff --git a/crates/features/src/tenancy/queue.rs b/crates/features/src/tenancy/queue.rs new file mode 100644 index 0000000..589dc97 --- /dev/null +++ b/crates/features/src/tenancy/queue.rs @@ -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> { + let mut names = AHashSet::new(); + for id in registry + .query::>(RegistryQuery::new(ObjectType::Domain).with_tenant(Some(tenant_id))) + .await + .caused_by(trc::location!())? + { + if let Some(domain) = registry.object::(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 { + 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, + recipients: impl IntoIterator, + 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 { + ["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(), ["a@T.example"], "x@u.example", false)); + assert!(sees( + &domains(), + ["b@u.example", "a@t.example"], + "x@u.example", + true + )); + } + + #[test] + fn sent_by_the_tenant() { + assert!(sees(&domains(), ["b@u.example"], "a@t.example", 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(), ["b@u.example"], "a@t.example", false)); + } + + #[test] + fn nothing_else() { + assert!(!sees(&domains(), ["b@u.example"], "x@u.example", true)); + assert!(!sees(&domains(), ["b@u.example"], "<>", true)); + } +} diff --git a/crates/features/src/tenancy/quota.rs b/crates/features/src/tenancy/quota.rs new file mode 100644 index 0000000..595fe22 --- /dev/null +++ b/crates/features/src/tenancy/quota.rs @@ -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 { + 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)> { + 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) -> Option { + 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 { + 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::>(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, + adding: u64, +) -> trc::Result> { + 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 { + 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 { + 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 { + let members = registry + .query::>(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> { + registry + .query::>(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()); + } +} diff --git a/crates/features/src/tenancy/reach.rs b/crates/features/src/tenancy/reach.rs new file mode 100644 index 0000000..720e3e1 --- /dev/null +++ b/crates/features/src/tenancy/reach.rs @@ -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:?}"); + } + } +} diff --git a/crates/features/src/tenancy/writes.rs b/crates/features/src/tenancy/writes.rs new file mode 100644 index 0000000..73c2c89 --- /dev/null +++ b/crates/features/src/tenancy/writes.rs @@ -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, +} + +/// The domain a principal lives on. +fn principal_domain(object: &ObjectInner) -> Option { + 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_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 Option> { + let quotas = registry + .object::(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, + old: Option<&Object>, + new: &Object, +) -> trc::Result>> { + 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> { + 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 { + 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 { + 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) -> SetError { + 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()), + } +}