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;
|
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> {
|
||||||
|
|||||||
Vendored
+54
-2
@@ -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('@') {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user