From f72e3bb85cf9c18281fcf59f7938a6450165f1a5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 10:32:57 -0700 Subject: [PATCH] Per-domain directories: each domain signs in against its own directory (DIR-1 to DIR-15) The two lookups every caller uses now honor Domain.directoryId, then the server default, then the internal directory, so sign-in, bearer routing, recipient lookup, discovery, the PACC record and the refusal of password changes on external accounts all follow the domain. A directoryId, or a server default, naming a directory that doesn't exist is unavailable, never the internal directory. A directory speaks only for the domains it serves: an account it returns on another directory's domain is refused, for sign-in and recipients alike, and aliases and group claims on such domains are dropped with a warning. A bearer token must belong to the user the client names, or the name must be one of its aliases with alias sign-in allowed. Accounts and groups that sync creates pass the tenant checks, limits included. --- crates/common/src/auth/authentication.rs | 112 +++++++++++++++++++++-- crates/common/src/cache/directory.rs | 56 +++++++++++- crates/common/src/network/mta.rs | 11 +++ crates/directory/src/core/config.rs | 7 +- 4 files changed, 173 insertions(+), 13 deletions(-) diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 981c29e..3dcb90c 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -184,7 +184,7 @@ impl Server { }; is_alias_login = directory_account.email != auth_as_address; - self.build_directory_token(directory_account, req.remote_ip) + self.build_directory_token(directory, directory_account, req.remote_ip) .await } else if let Some(account_id) = self.account_id_from_parts(auth_as_local, domain.id).await? @@ -341,7 +341,38 @@ impl Server { { match directory.authenticate(&req.credentials).await { Ok(result) => { - return self.build_directory_token(result, req.remote_ip).await; + // inbuxa: DIR-7: the token must be the named user's, or + // the named address an alias it may sign in with + let named = username + .as_deref() + .map(|name| UsernameParts::new(name).auth_as().address().to_lowercase()); + let is_alias = match &named { + Some(named) if !named.eq_ignore_ascii_case(&result.email) => { + if !result + .email_aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(named)) + { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, named.clone()) + .details(result.email.clone()) + .reason("The token belongs to a different user")); + } + true + } + _ => false, + }; + let token = self + .build_directory_token(directory, result, req.remote_ip) + .await?; + if is_alias && !token.has_permission(Permission::AuthenticateWithAlias) { + return Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountId, token.account_id()) + .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); + } + return Ok(token); } Err(err) => { external_error = Some(err); @@ -382,6 +413,8 @@ impl Server { && let Some(directory) = self.get_directory_for_cached_domain(&domain_cache) && let Recipient::Account(account) = directory.recipient(address).await? { + // inbuxa: DIR-6 + self.assert_directory_serves(directory, &account.email).await?; return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id)); } @@ -503,29 +536,88 @@ impl Server { async fn build_directory_token( &self, + directory: &Arc, account: directory::Account, remote_ip: IpAddr, ) -> trc::Result { + // inbuxa: DIR-6 + self.assert_directory_serves(directory, &account.email).await?; let account = Box::pin(self.synchronize_account(account)).await?; self.access_token_from_account(account.id, account.account) .await .and_then(|token| AccessToken::new(token, remote_ip)) } + /// inbuxa: DIR-1, DIR-5: the directory a domain signs in against: its + /// own, else the server default, else the internal one (`None`). An + /// unknown domain gets the server default. pub async fn get_directory_for_domain( &self, - // inbuxa: unused until per-domain directories (Domain.directoryId) are rebuilt; see docs/spec/SPEC.md ยง4 - _domain_name: &str, + domain_name: &str, ) -> trc::Result>> { - - Ok(self.get_default_directory()) + Ok(match self.domain(domain_name).await? { + Some(domain) => self.get_directory_for_cached_domain(&domain), + None => self.get_default_directory(), + }) } - // inbuxa: `_domain` is unused until per-domain directories (Domain.directoryId) are rebuilt - pub fn get_directory_for_cached_domain(&self, _domain: &DomainCache) -> Option<&Arc> { - - self.get_default_directory() + /// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A + /// `directoryId` naming no directory the server built is unavailable, + /// never the internal directory. + pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc> { + match domain.id_directory { + Some(directory_id) => Some( + self.core + .storage + .directories + .get(&directory_id) + .unwrap_or_else(|| { + trc::event!( + Auth(trc::AuthEvent::Warning), + Domain = domain.name().to_string(), + Id = directory_id, + Reason = "The domain's directory doesn't exist; sign-in fails", + ); + unavailable_directory() + }), + ), + None => self.get_default_directory(), + } } + + /// inbuxa: DIR-6: a directory speaks only for the domains it serves. + pub async fn assert_directory_serves( + &self, + directory: &Arc, + address: &str, + ) -> trc::Result<()> { + let serves = match address.rsplit_once('@') { + Some((_, domain)) => self + .get_directory_for_domain(domain) + .await? + .is_some_and(|effective| Arc::ptr_eq(effective, directory)), + None => false, + }; + if serves { + Ok(()) + } else { + Err(trc::AuthEvent::Failed + .into_err() + .ctx(trc::Key::AccountName, address.to_string()) + .reason("The directory returned an account on a domain it doesn't serve")) + } + } +} + +/// inbuxa: DIR-5: what a dangling `directoryId` resolves to. +pub fn unavailable_directory() -> &'static Arc { + static UNAVAILABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + UNAVAILABLE.get_or_init(|| { + Arc::new(Directory::Unavailable(directory::UnavailableDirectory::new( + registry::schema::enums::DirectoryType::Ldap, + "The directory named by the domain doesn't exist", + ))) + }) } fn extract_jwt_domain(token: &str) -> Option { diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index 2a7790c..30480d7 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -87,6 +87,7 @@ impl Server { for alias in account.email_aliases { if let Some((local, alias_domain)) = self.validate_alias(&alias).await? && alias_domain.id_tenant == domain.id_tenant + && self.same_directory(&domain, &alias).await? && self .rcpt_id_from_parts(local, alias_domain.id) .await? @@ -105,7 +106,9 @@ impl Server { let mut member_group_ids = Vec::with_capacity(groups.len()); for email in groups { // inbuxa: SCIM-58: no group comes from a claim on a SCIM domain - if self.is_scim_address(&email).await? { + if self.is_scim_address(&email).await? + || !self.same_directory(&domain, &email).await? + { continue; } member_group_ids.push( @@ -179,6 +182,7 @@ impl Server { for alias in account.email_aliases { if let Some((local, alias_domain)) = self.validate_alias(&alias).await? && alias_domain.id_tenant == domain.id_tenant + && self.same_directory(&domain, &alias).await? && self .rcpt_id_from_parts(local, alias_domain.id) .await? @@ -195,7 +199,9 @@ impl Server { let mut member_group_ids = Vec::new(); for email in account.groups.unwrap_or_default() { // inbuxa: SCIM-58: no group comes from a claim on a SCIM domain - if self.is_scim_address(&email).await? { + if self.is_scim_address(&email).await? + || !self.same_directory(&domain, &email).await? + { continue; } member_group_ids.push( @@ -228,6 +234,8 @@ impl Server { })); + // inbuxa: DIR-15 + self.check_tenant_limits(&account).await?; match self .registry() .write(RegistryWrite::insert(&account)) @@ -303,6 +311,7 @@ impl Server { for alias in group.email_aliases { if let Some((local, alias_domain)) = self.validate_alias(&alias).await? && alias_domain.id_tenant == domain.id_tenant + && self.same_directory(&domain, &alias).await? && self .rcpt_id_from_parts(local, alias_domain.id) .await? @@ -362,6 +371,7 @@ impl Server { for alias in group.email_aliases { if let Some((local, alias_domain)) = self.validate_alias(&alias).await? && alias_domain.id_tenant == domain.id_tenant + && self.same_directory(&domain, &alias).await? && self .rcpt_id_from_parts(local, alias_domain.id) .await? @@ -388,6 +398,8 @@ impl Server { })); + // inbuxa: DIR-15 + self.check_tenant_limits(&account).await?; match self .registry() .write(RegistryWrite::insert(&account)) @@ -413,6 +425,46 @@ impl Server { } } + /// inbuxa: DIR-6: whether an address is on a domain served by the same + /// directory as `domain`; a warning when it isn't. + async fn same_directory(&self, domain: &DomainCache, address: &str) -> trc::Result { + let Some((_, other)) = address.rsplit_once('@') else { + return Ok(true); + }; + let Some(other) = self.domain(other).await? else { + return Ok(true); + }; + let same = match ( + self.get_directory_for_cached_domain(domain), + self.get_directory_for_cached_domain(&other), + ) { + (None, None) => true, + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + _ => false, + }; + if !same { + trc::event!( + Auth(trc::AuthEvent::Warning), + AccountName = address.to_string(), + Domain = other.name().to_string(), + Reason = "Dropped: the address is on a domain served by another directory", + ); + } + Ok(same) + } + + /// inbuxa: DIR-15, MT-3, MT-17: an object created from a directory + /// passes the same tenant checks as one created over JMAP. + async fn check_tenant_limits(&self, object: &Object) -> trc::Result<()> { + match inbuxa_features::tenancy::writes::check(self.registry(), None, None, object).await? { + Ok(_) => Ok(()), + Err(err) => Err(trc::AuthEvent::Failed + .into_err() + .details(err.description().unwrap_or("A tenant limit is reached").to_string()) + .reason("The directory's account can't be created")), + } + } + /// inbuxa: SCIM-58: whether an address is on a domain SCIM manages. async fn is_scim_address(&self, address: &str) -> trc::Result { Ok(match address.rsplit_once('@') { diff --git a/crates/common/src/network/mta.rs b/crates/common/src/network/mta.rs index 249c36b..c087c7d 100644 --- a/crates/common/src/network/mta.rs +++ b/crates/common/src/network/mta.rs @@ -106,6 +106,17 @@ impl Server { Cow::Borrowed(rcpt) }; match directory.recipient(address.as_ref()).await? { + // inbuxa: DIR-6: an answer for another directory's domain is no answer + Recipient::Account(account) + if self + .assert_directory_serves(directory, &account.email) + .await + .is_err() => {} + Recipient::Group(group) + if self + .assert_directory_serves(directory, &group.email) + .await + .is_err() => {} Recipient::Account(account) => { Box::pin(self.synchronize_account(account)).await?; return Ok(if is_subaddressed { diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs index eb1b34f..0efe5c2 100644 --- a/crates/directory/src/core/config.rs +++ b/crates/directory/src/core/config.rs @@ -49,7 +49,12 @@ impl Directories { ObjectType::Authentication.singleton(), format!("Default directory with ID {} not found", directory_id), ); - None + // inbuxa: DIR-5: a missing default is unavailable, never the + // internal directory + Some(Arc::new(Directory::Unavailable(UnavailableDirectory::new( + registry::schema::enums::DirectoryType::Ldap, + format!("Default directory with ID {} not found", directory_id), + )))) } } } else {