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.
This commit is contained in:
2026-09-19 10:32:57 -07:00
parent b080fa0736
commit f72e3bb85c
4 changed files with 173 additions and 13 deletions
+102 -10
View File
@@ -184,7 +184,7 @@ impl Server {
}; };
is_alias_login = directory_account.email != auth_as_address; 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 .await
} else if let Some(account_id) = } else if let Some(account_id) =
self.account_id_from_parts(auth_as_local, domain.id).await? self.account_id_from_parts(auth_as_local, domain.id).await?
@@ -341,7 +341,38 @@ impl Server {
{ {
match directory.authenticate(&req.credentials).await { match directory.authenticate(&req.credentials).await {
Ok(result) => { 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) => { Err(err) => {
external_error = Some(err); external_error = Some(err);
@@ -382,6 +413,8 @@ impl Server {
&& let Some(directory) = self.get_directory_for_cached_domain(&domain_cache) && let Some(directory) = self.get_directory_for_cached_domain(&domain_cache)
&& let Recipient::Account(account) = directory.recipient(address).await? && 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)); return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id));
} }
@@ -503,29 +536,88 @@ impl Server {
async fn build_directory_token( async fn build_directory_token(
&self, &self,
directory: &Arc<Directory>,
account: directory::Account, account: directory::Account,
remote_ip: IpAddr, remote_ip: IpAddr,
) -> trc::Result<AccessToken> { ) -> trc::Result<AccessToken> {
// inbuxa: DIR-6
self.assert_directory_serves(directory, &account.email).await?;
let account = Box::pin(self.synchronize_account(account)).await?; let account = Box::pin(self.synchronize_account(account)).await?;
self.access_token_from_account(account.id, account.account) self.access_token_from_account(account.id, account.account)
.await .await
.and_then(|token| AccessToken::new(token, remote_ip)) .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( pub async fn get_directory_for_domain(
&self, &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<Option<&Arc<Directory>>> { ) -> trc::Result<Option<&Arc<Directory>>> {
Ok(match self.domain(domain_name).await? {
Ok(self.get_default_directory()) 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 /// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
pub fn get_directory_for_cached_domain(&self, _domain: &DomainCache) -> Option<&Arc<Directory>> { /// `directoryId` naming no directory the server built is unavailable,
/// never the internal directory.
self.get_default_directory() pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc<Directory>> {
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<Directory>,
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<Directory> {
static UNAVAILABLE: std::sync::OnceLock<Arc<Directory>> = 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<String> { fn extract_jwt_domain(token: &str) -> Option<String> {
+54 -2
View File
@@ -87,6 +87,7 @@ impl Server {
for alias in account.email_aliases { for alias in account.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -105,7 +106,9 @@ impl Server {
let mut member_group_ids = Vec::with_capacity(groups.len()); let mut member_group_ids = Vec::with_capacity(groups.len());
for email in groups { for email in groups {
// inbuxa: SCIM-58: no group comes from a claim on a SCIM domain // 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; continue;
} }
member_group_ids.push( member_group_ids.push(
@@ -179,6 +182,7 @@ impl Server {
for alias in account.email_aliases { for alias in account.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -195,7 +199,9 @@ impl Server {
let mut member_group_ids = Vec::new(); let mut member_group_ids = Vec::new();
for email in account.groups.unwrap_or_default() { for email in account.groups.unwrap_or_default() {
// inbuxa: SCIM-58: no group comes from a claim on a SCIM domain // 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; continue;
} }
member_group_ids.push( member_group_ids.push(
@@ -228,6 +234,8 @@ impl Server {
})); }));
// inbuxa: DIR-15
self.check_tenant_limits(&account).await?;
match self match self
.registry() .registry()
.write(RegistryWrite::insert(&account)) .write(RegistryWrite::insert(&account))
@@ -303,6 +311,7 @@ impl Server {
for alias in group.email_aliases { for alias in group.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -362,6 +371,7 @@ impl Server {
for alias in group.email_aliases { for alias in group.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -388,6 +398,8 @@ impl Server {
})); }));
// inbuxa: DIR-15
self.check_tenant_limits(&account).await?;
match self match self
.registry() .registry()
.write(RegistryWrite::insert(&account)) .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<bool> {
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. /// inbuxa: SCIM-58: whether an address is on a domain SCIM manages.
async fn is_scim_address(&self, address: &str) -> trc::Result<bool> { async fn is_scim_address(&self, address: &str) -> trc::Result<bool> {
Ok(match address.rsplit_once('@') { Ok(match address.rsplit_once('@') {
+11
View File
@@ -106,6 +106,17 @@ impl Server {
Cow::Borrowed(rcpt) Cow::Borrowed(rcpt)
}; };
match directory.recipient(address.as_ref()).await? { 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) => { Recipient::Account(account) => {
Box::pin(self.synchronize_account(account)).await?; Box::pin(self.synchronize_account(account)).await?;
return Ok(if is_subaddressed { return Ok(if is_subaddressed {
+6 -1
View File
@@ -49,7 +49,12 @@ impl Directories {
ObjectType::Authentication.singleton(), ObjectType::Authentication.singleton(),
format!("Default directory with ID {} not found", directory_id), 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 { } else {