Multi-tenancy: registry reach, links, membership and count limits over JMAP (MT-2, MT-3, MT-6, MT-7, MT-8, MT-11, MT-12, MT-17, MT-18)

Inside a tenant, server-level object types are forbidden and x:Tenant reads
return only the caller's own tenant, which it can't change. Registry writes
refuse links across tenant boundaries in both directions, give a new
principal its domain's tenant, move a domain's principals and DKIM keys with
it into a tenant, refuse moves out of a tenant while its people remain, and
refuse creates past a tenant's count limits with overQuota and
limit.tenant-quota.
This commit is contained in:
2026-09-18 15:20:04 -07:00
parent ad0db8b2b0
commit 5a22e79992
5 changed files with 84 additions and 0 deletions
+1
View File
@@ -19,6 +19,7 @@ spam-filter = { path = "../spam-filter" }
email = { path = "../email" } email = { path = "../email" }
groupware = { path = "../groupware" } groupware = { path = "../groupware" }
registry = { path = "../registry" } registry = { path = "../registry" }
inbuxa-features = { path = "../features" }
calcard = { version = "0.3" } calcard = { version = "0.3" }
smtp-proto = { version = "0.2" } smtp-proto = { version = "0.2" }
mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] }
+26
View File
@@ -89,6 +89,11 @@ impl JmapAuthorization for AccessToken {
let MethodObject::Registry(object_type) = object else { let MethodObject::Registry(object_type) = object else {
unreachable!() unreachable!()
}; };
// inbuxa: MT-2: server-level objects are out of a tenant's reach
assert_tenant_reach(
self,
inbuxa_features::tenancy::reach::can_read(object_type),
)?;
object_type.get_permission() object_type.get_permission()
} }
}, },
@@ -203,6 +208,11 @@ impl JmapAuthorization for AccessToken {
let MethodObject::Registry(object_type) = object else { let MethodObject::Registry(object_type) = object else {
unreachable!() unreachable!()
}; };
// inbuxa: MT-2, MT-12: server-level objects are out of a tenant's reach
assert_tenant_reach(
self,
inbuxa_features::tenancy::reach::can_write(object_type),
)?;
let set_permissions = object_type.set_permission(); let set_permissions = object_type.set_permission();
validate_set( validate_set(
s, s,
@@ -295,6 +305,11 @@ impl JmapAuthorization for AccessToken {
let MethodObject::Registry(object_type) = object else { let MethodObject::Registry(object_type) = object else {
unreachable!() unreachable!()
}; };
// inbuxa: MT-2: server-level objects are out of a tenant's reach
assert_tenant_reach(
self,
inbuxa_features::tenancy::reach::can_read(object_type),
)?;
object_type.query_permission() object_type.query_permission()
} }
}, },
@@ -316,6 +331,17 @@ impl JmapAuthorization for AccessToken {
} }
} }
// inbuxa: MT-2
fn assert_tenant_reach(access_token: &AccessToken, reachable: bool) -> trc::Result<()> {
if reachable || access_token.tenant_id().is_none() {
Ok(())
} else {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("You are not authorized to perform this action"))
}
}
fn validate_set<T: JmapObject>( fn validate_set<T: JmapObject>(
set: &SetRequest<'_, T>, set: &SetRequest<'_, T>,
access_token: &AccessToken, access_token: &AccessToken,
+10
View File
@@ -198,6 +198,11 @@ impl RegistryGet for Server {
let ids = if let Some(ids) = get.ids.take() { let ids = if let Some(ids) = get.ids.take() {
ids ids
} else if object_type == ObjectType::Tenant
&& let Some(tenant_id) = access_token.tenant_id()
{
// inbuxa: MT-12: inside a tenant, only its own tenant object
vec![Id::from(tenant_id)]
} else { } else {
self.registry() self.registry()
.query::<Vec<Id>>( .query::<Vec<Id>>(
@@ -223,6 +228,11 @@ impl RegistryGet for Server {
!= object.inner.member_tenant_id()) != object.inner.member_tenant_id())
|| (is_account_filtered || (is_account_filtered
&& object.inner.account_id() != Some(Id::from(get.account_id))) && object.inner.account_id() != Some(Id::from(get.account_id)))
// inbuxa: MT-12: inside a tenant, only its own tenant object
|| (object_type == ObjectType::Tenant
&& access_token
.tenant_id()
.is_some_and(|tenant_id| tenant_id != id.document_id()))
{ {
get.not_found(id); get.not_found(id);
continue; continue;
+8
View File
@@ -265,6 +265,14 @@ impl RegistryQuery for Server {
} }
}; };
// inbuxa: MT-12: inside a tenant, only its own tenant object
let mut results = results;
if object_type == ObjectType::Tenant
&& let Some(tenant_id) = access_token.tenant_id()
{
results.retain(|id| id.document_id() == tenant_id);
}
// Build response // Build response
let mut response = QueryResponseBuilder::new( let mut response = QueryResponseBuilder::new(
results.len(), results.len(),
+39
View File
@@ -381,6 +381,15 @@ impl RegistrySet for Server {
new_object.inner.set_member_tenant_id(tenant_id.into()); new_object.inner.set_member_tenant_id(tenant_id.into());
} }
// inbuxa: MT-7: a principal takes its domain's tenant
if can_set_tenant {
inbuxa_features::tenancy::writes::default_tenant(
self.registry(),
&mut new_object,
)
.await?;
}
// Add accountId // Add accountId
if has_account_id { if has_account_id {
new_object.inner.set_account_id(set.account_id.into()); new_object.inner.set_account_id(set.account_id.into());
@@ -526,6 +535,26 @@ impl RegistrySet for Server {
} }
}; };
// inbuxa: MT-3, MT-8, MT-17
let (stored_id, stored) = match &modification {
Modification::Update { id, object } => (Some(*id), Some(object)),
Modification::Create { .. } => (None, None),
};
let after_save = match inbuxa_features::tenancy::writes::check(
self.registry(),
stored_id,
stored,
&new_object,
)
.await?
{
Ok(after_save) => after_save,
Err(err) => {
set.failed(modification, err);
continue 'outer;
}
};
// Validate expressions // Validate expressions
if let Some(expressions) = new_object.inner.expression_ctxs() { if let Some(expressions) = new_object.inner.expression_ctxs() {
let mut bp = Bootstrap::new_uninitialized(self.registry().clone()); let mut bp = Bootstrap::new_uninitialized(self.registry().clone());
@@ -590,6 +619,16 @@ impl RegistrySet for Server {
let object_id = match (modification, result) { let object_id = match (modification, result) {
(Modification::Update { id, object }, RegistryWriteResult::Success(_)) => { (Modification::Update { id, object }, RegistryWriteResult::Success(_)) => {
cache_invalidator.process_update(id, &object, &new_object); cache_invalidator.process_update(id, &object, &new_object);
// inbuxa: MT-8: what moves with a domain follows it
for (id, old, new) in inbuxa_features::tenancy::writes::after_save(
&self.core.storage.data,
self.registry(),
after_save,
)
.await?
{
cache_invalidator.process_update(id, &old, &new);
}
if let ( if let (
ObjectInner::Application(previous), ObjectInner::Application(previous),
ObjectInner::Application(updated), ObjectInner::Application(updated),