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:
@@ -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,31 +536,90 @@ impl Server {
|
||||
|
||||
async fn build_directory_token(
|
||||
&self,
|
||||
directory: &Arc<Directory>,
|
||||
account: directory::Account,
|
||||
remote_ip: IpAddr,
|
||||
) -> trc::Result<AccessToken> {
|
||||
// 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<Option<&Arc<Directory>>> {
|
||||
|
||||
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<Directory>> {
|
||||
|
||||
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<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> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
|
||||
Vendored
+54
-2
@@ -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<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.
|
||||
async fn is_scim_address(&self, address: &str) -> trc::Result<bool> {
|
||||
Ok(match address.rsplit_once('@') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user