Per-domain directories: changes apply on the next request, and one broken directory doesn't block reloads (DIR-17, DIR-21)

A write to x:Directory or Authentication reloads the directories at once,
here and across the cluster. A directory that fails to open is logged as
a warning against its id and becomes unavailable; before, it was a build
error, and any build error stopped every later reload from applying.

per_domain_directory_tests covers acceptance tests 1, 3, 4, 6, 7, 9, 11,
15 and 19 over SQL directories on SQLite files: each domain against its
own directory and the default, no fallback to internal passwords, a
directory answering for another directory's domain, aliases and groups
dropped, recipients through the directory and a 4xx while it's down, app
passwords while it's down, password changes refused and then allowed
after a move to the internal directory, linked directories, tenant
foreign keys, and changes taking effect without a reload.
This commit is contained in:
2026-09-19 10:42:33 -07:00
parent f72e3bb85c
commit 3158277b04
4 changed files with 504 additions and 3 deletions
+4 -2
View File
@@ -33,7 +33,9 @@ impl Directories {
let directory = match result { let directory = match result {
Ok(directory) => directory, Ok(directory) => directory,
Err(err) => { Err(err) => {
bp.build_error(id, err.clone()); // inbuxa: DIR-21: logged against the directory, which becomes
// unavailable; the rest of the reload carries on
bp.build_warning(id, err.clone());
Directory::Unavailable(UnavailableDirectory::new(directory_type, err)) Directory::Unavailable(UnavailableDirectory::new(directory_type, err))
} }
}; };
@@ -45,7 +47,7 @@ impl Directories {
match directories.get(&(directory_id.id() as u32)) { match directories.get(&(directory_id.id() as u32)) {
Some(default_directory) => default_directory.clone().into(), Some(default_directory) => default_directory.clone().into(),
None => { None => {
bp.build_error( bp.build_warning(
ObjectType::Authentication.singleton(), ObjectType::Authentication.singleton(),
format!("Default directory with ID {} not found", directory_id), format!("Default directory with ID {} not found", directory_id),
); );
+28 -1
View File
@@ -167,7 +167,7 @@ impl RegistrySet for Server {
update, update,
destroy, destroy,
}; };
match object_type { let result = match object_type {
ObjectType::AddressBook ObjectType::AddressBook
| ObjectType::Asn | ObjectType::Asn
| ObjectType::Authentication | ObjectType::Authentication
@@ -915,7 +915,34 @@ impl RegistrySet for Server {
set.fail_all_destroy("Enterprise objects cannot be deleted"); set.fail_all_destroy("Enterprise objects cannot be deleted");
Ok(set.into_response()) Ok(set.into_response())
} }
};
// inbuxa: DIR-17: a directory or the server default applies on the
// next request, here and on every node
if matches!(
object_type,
ObjectType::Directory | ObjectType::Authentication
) && let Ok(response) = &result
&& (!response.created.is_empty()
|| !response.updated.is_empty()
|| !response.destroyed.is_empty())
{
let change = common::ipc::RegistryChange::Reload(ObjectType::Directory);
match Box::pin(self.reload_registry(change)).await {
Ok(reload) if !reload.has_errors() => {
self.cluster_broadcast(common::ipc::BroadcastEvent::RegistryChange(change))
.await;
} }
Ok(_) => trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Details = "Settings didn't reload after a directory change",
),
Err(err) => {
trc::error!(err.details("Failed to reload directories"));
}
}
}
result
} }
} }
+2
View File
@@ -8,6 +8,8 @@ pub mod discovery;
pub mod integration; pub mod integration;
pub mod ldap; pub mod ldap;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
pub mod per_domain; // inbuxa: per-domain directories
#[cfg(feature = "sqlite")]
pub mod sql; pub mod sql;
pub mod synchronization; pub mod synchronization;
pub mod unavailable; pub mod unavailable;
+470
View File
@@ -0,0 +1,470 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Per-domain directory acceptance tests, from
//! `docs/spec/features/per-domain-directories.md`, over SQL directories on
//! SQLite files, so no container is needed. Each check names its test
//! number or requirement.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
smtp::SmtpConnection,
};
use common::{BuildServer, auth::AuthRequest};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{
self, Authentication, CertificateManagement, DkimManagement, DnsManagement, Domain,
Expression, MtaStageRcpt, PasswordCredential, SqlAuthStore, SqlDirectory, SqliteStore,
Tenant, UserAccount,
},
};
use serde_json::json;
use std::net::IpAddr;
use types::id::Id;
/// An SQLite directory database with its rows.
async fn directory_db(path: &str, accounts: &[(&str, &str, &str, &str, &str)], extra: &[&str]) {
let store = store::backend::sqlite::SqliteStore::open(SqliteStore {
path: path.to_string(),
..Default::default()
})
.unwrap();
let mut queries = vec![
"CREATE TABLE accounts (login TEXT PRIMARY KEY, name TEXT, secret TEXT, description TEXT, type TEXT NOT NULL, active BOOLEAN DEFAULT TRUE)".to_string(),
"CREATE TABLE group_members (name TEXT NOT NULL, member_of TEXT NOT NULL, PRIMARY KEY (name, member_of))".to_string(),
"CREATE TABLE emails (name TEXT NOT NULL, address TEXT NOT NULL, PRIMARY KEY (name, address))".to_string(),
];
for (login, name, secret, description, typ) in accounts {
queries.push(format!(
"INSERT INTO accounts (login, name, secret, description, type) VALUES ('{login}', '{name}', '{secret}', '{description}', '{typ}')"
));
}
queries.extend(extra.iter().map(|q| q.to_string()));
for query in queries {
store
.sql_query::<usize>(&query, vec![])
.await
.unwrap_or_else(|err| panic!("{query}: {err:?}"));
}
}
fn sql_directory(path: &str, description: &str) -> structs::Directory {
structs::Directory::Sql(SqlDirectory {
description: description.to_string(),
query_login: "SELECT name, secret, description, type FROM accounts WHERE login = $1 AND active = true".into(),
query_recipient: "SELECT name, secret, description, type FROM accounts WHERE name = $1 AND active = true".into(),
query_email_aliases: Some("SELECT address FROM emails WHERE name = $1".into()),
query_member_of: Some("SELECT member_of FROM group_members WHERE name = $1".into()),
column_class: Some("type".into()),
column_description: Some("description".into()),
column_email: "name".into(),
column_secret: "secret".into(),
store: SqlAuthStore::Sqlite(SqliteStore {
path: path.to_string(),
..Default::default()
}),
member_tenant_id: None,
})
}
/// A directory that fails to open at once: the test build has no MySQL
/// backend, so it's unavailable (a bad SQLite path would make every reload
/// wait out the pool's connection timeout).
fn broken_directory(description: &str) -> structs::Directory {
let structs::Directory::Sql(mut sql) = sql_directory("", description) else {
unreachable!()
};
sql.store = SqlAuthStore::MySql(structs::MySqlStore {
host: "127.0.0.1".into(),
port: 3306,
database: "none".into(),
..Default::default()
});
structs::Directory::Sql(sql)
}
fn domain(name: &str, directory_id: Option<Id>) -> Domain {
Domain {
is_enabled: true,
name: name.to_string(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
directory_id,
..Default::default()
}
}
async fn sign_in(test: &TestServer, user: &str, secret: &str) -> trc::Result<u32> {
// The core as it is now, after any reload
test.server
.inner
.build_server()
.authenticate(&AuthRequest::from_plain(
user,
secret,
0,
IpAddr::from([127, 0, 0, 1]),
))
.await
.map(|token| token.account_id())
}
async fn account_of(test: &TestServer, address: &str) -> Option<u32> {
test.server
.inner
.build_server()
.account_id_from_email(address, false)
.await
.unwrap()
}
async fn user(test: &TestServer, id: u32) -> UserAccount {
test.account("admin")
.registry_get::<structs::Account>(Id::from(id))
.await
.into_user()
.unwrap()
}
async fn set_directory(test: &TestServer, domain_id: Id, directory: Option<Id>) {
test.account("admin")
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({ Property::DirectoryId: directory.map(|id| id.to_string()) }),
)
.await;
}
async fn rcpt(address: &str) -> char {
let mut lmtp = SmtpConnection::connect().await;
lmtp.mail_from("[email protected]", 2).await;
lmtp.send(&format!("RCPT TO:<{address}>")).await;
let reply = lmtp.read(1, u8::MAX).await;
lmtp.quit().await;
reply
.last()
.and_then(|line| line.chars().next())
.unwrap_or('?')
}
/// `cargo test -p tests per_domain_directory_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn per_domain_directory_tests() {
let test = TestServerBuilder::new("per_domain_directory_tests")
.await
.with_default_listeners()
.await
.with_object(MtaStageRcpt {
wait_on_fail: Expression {
else_: "1ms".into(),
..Default::default()
},
..Default::default()
})
.await
.build()
.await;
let admin = test.account("admin");
admin.mta_no_auth().await;
admin.mta_disable_spam_filter().await;
admin.reload_settings().await;
let base = test.temp_dir.path.to_str().unwrap().to_string();
let path_a = format!("{base}/directory-a.sqlite");
let path_b = format!("{base}/directory-b.sqlite");
directory_db(
&path_a,
&[
(
"[email protected]",
"[email protected]",
"alice secret",
"Alice",
"individual",
),
(
"[email protected]",
"[email protected]",
"dave secret",
"Dave",
"individual",
),
(
"[email protected]",
"[email protected]",
"mallory secret",
"Mallory",
"individual",
),
(
"[email protected]",
"[email protected]",
"carol directory secret",
"Carol",
"individual",
),
("[email protected]", "[email protected]", "", "Team", "group"),
],
&[
"INSERT INTO emails (name, address) VALUES ('[email protected]', '[email protected]')",
"INSERT INTO emails (name, address) VALUES ('[email protected]', '[email protected]')",
"INSERT INTO group_members (name, member_of) VALUES ('[email protected]', '[email protected]')",
"INSERT INTO group_members (name, member_of) VALUES ('[email protected]', '[email protected]')",
],
)
.await;
directory_db(
&path_b,
&[(
"[email protected]",
"[email protected]",
"bob secret",
"Bob",
"individual",
)],
&[],
)
.await;
let dir_a = admin
.registry_create_object(sql_directory(&path_a, "A"))
.await;
let dir_b = admin
.registry_create_object(sql_directory(&path_b, "B"))
.await;
let broken = admin
.registry_create_object(broken_directory("Broken"))
.await;
let a = admin
.registry_create_object(domain("a.test", Some(dir_a)))
.await;
let b = admin
.registry_create_object(domain("b.test", Some(dir_b)))
.await;
let c = admin.registry_create_object(domain("c.test", None)).await;
// d.test gets its broken directory after dora's internal password exists,
// since passwords can't be set on a directory domain (DIR-13)
let d = admin.registry_create_object(domain("d.test", None)).await;
admin
.registry_create_object(structs::Account::User(UserAccount {
name: "ghost".to_string(),
domain_id: a,
..Default::default()
}))
.await;
for (name, domain_id) in [("carol", c), ("dora", d)] {
admin
.registry_create_object(structs::Account::User(UserAccount {
name: name.to_string(),
domain_id,
credentials: registry::types::list::List::from_iter([
structs::Credential::Password(PasswordCredential {
secret: format!("{name} internal secret"),
..Default::default()
}),
]),
..Default::default()
}))
.await;
}
set_directory(&test, d, Some(broken)).await;
// Test 1: each domain against its own directory (DIR-1)
let alice = sign_in(&test, "[email protected]", "alice secret")
.await
.expect("test 1: alice through A");
sign_in(&test, "[email protected]", "bob secret")
.await
.expect("test 1: bob through B");
sign_in(&test, "[email protected]", "carol internal secret")
.await
.expect("test 1: carol, internal");
assert!(
sign_in(&test, "[email protected]", "alice secret").await.is_err(),
"test 1"
);
// DIR-14, DIR-6: aliases and groups on another directory's domain dropped
let account = user(&test, alice).await;
assert_eq!(account.description.as_deref(), Some("Alice"), "DIR-14");
let aliases = account
.aliases
.values()
.map(|alias| alias.name.clone())
.collect::<Vec<_>>();
assert_eq!(aliases, vec!["alice.a".to_string()], "test 4: DIR-6");
assert_eq!(account.member_group_ids.len(), 1, "test 4: DIR-6");
assert!(account_of(&test, "[email protected]").await.is_some(), "DIR-14");
assert!(
account_of(&test, "[email protected]").await.is_none(),
"test 4: DIR-6"
);
// With A as the server default, C signs in against A (DIR-1)
admin
.registry_update_setting(
Authentication {
directory_id: Some(dir_a),
..Default::default()
},
&[Property::DirectoryId],
)
.await;
assert!(
sign_in(&test, "[email protected]", "carol internal secret")
.await
.is_err(),
"test 1: no longer internal"
);
sign_in(&test, "[email protected]", "carol directory secret")
.await
.expect("test 1: carol through the default");
sign_in(&test, "[email protected]", "bob secret")
.await
.expect("test 1: B unchanged");
admin
.registry_update_setting(
Authentication {
directory_id: None,
..Default::default()
},
&[Property::DirectoryId],
)
.await;
// Test 4: A answering for a B address: refused, nothing created (DIR-6)
assert!(
sign_in(&test, "[email protected]", "mallory secret")
.await
.is_err(),
"test 4"
);
assert!(
account_of(&test, "[email protected]").await.is_none(),
"test 4"
);
// Test 3: a domain whose directory failed to open (DIR-5, DIR-4)
assert!(
sign_in(&test, "[email protected]", "dora internal secret")
.await
.is_err(),
"test 3: never the internal store"
);
// Test 7: mail creates the account; an internal-only account on a
// directory domain isn't a recipient (DIR-9, DIR-14)
assert!(account_of(&test, "[email protected]").await.is_none());
assert_eq!(rcpt("[email protected]").await, '2', "test 7");
assert!(account_of(&test, "[email protected]").await.is_some(), "test 7");
assert_eq!(rcpt("[email protected]").await, '5', "test 7");
// Test 11: no password change on a directory account (DIR-13)
let refused = admin
.registry_update(
ObjectType::Account,
[(
Id::from(alice).to_string(),
json!({"credentials/0/secret": "a brand new strong password"}),
)],
)
.await;
assert_eq!(
refused.not_updated(&Id::from(alice).to_string())["type"],
json!("forbidden"),
"test 11"
);
// Test 6: an app password works while the directory is down (DIR-3)
let alice_account = Account::new("[email protected]", "alice secret", &[], "", Id::from(alice));
let app = alice_account
.registry_create([structs::AppPassword {
description: "mail client".to_string(),
..Default::default()
}])
.await;
let app_secret = app.created(0)["secret"].as_str().unwrap().to_string();
admin
.registry_update_object(
ObjectType::Directory,
dir_a,
json!({"store": {"@type": "MySql", "host": "127.0.0.1", "port": 3306, "database": "none"}}),
)
.await;
assert!(
sign_in(&test, "[email protected]", "alice secret")
.await
.is_err(),
"test 2: no fallback to the synchronized password (DIR-4)"
);
sign_in(&test, "[email protected]", &app_secret)
.await
.expect("test 6: app password (DIR-3)");
// Test 9: recipients on a domain whose directory is down: 4xx (DIR-11)
assert_eq!(rcpt("[email protected]").await, '4', "test 9");
// DIR-21: the broken directory didn't stop other changes applying
sign_in(&test, "[email protected]", "bob secret")
.await
.expect("DIR-21: B still works");
// Test 11 continued: moved to the internal directory, the synchronized
// password keeps working and can be changed (DIR-20)
set_directory(&test, a, None).await;
sign_in(&test, "[email protected]", "alice secret")
.await
.expect("test 11: DIR-20");
admin
.registry_update(
ObjectType::Account,
[(
Id::from(alice).to_string(),
json!({"credentials/0/secret": "a brand new strong password"}),
)],
)
.await
.updated_id(Id::from(alice));
// Test 19: a domain's directory changes on the next sign-in (DIR-17)
set_directory(&test, b, Some(dir_a)).await;
assert!(
sign_in(&test, "[email protected]", "bob secret").await.is_err(),
"test 19"
);
set_directory(&test, b, Some(dir_b)).await;
sign_in(&test, "[email protected]", "bob secret")
.await
.expect("test 19");
// Test 15: a directory in use can't be deleted (DIR-21); a tenant's
// domain can't name a server-level directory (DIR-22)
let response = admin.registry_destroy(ObjectType::Directory, [dir_b]).await;
assert_eq!(
response.not_destroyed(&dir_b.to_string())["type"],
json!("objectIsLinked"),
"test 15"
);
let tenant = admin
.registry_create_object(Tenant {
name: "dirtenant".into(),
..Default::default()
})
.await;
let mut tenant_domain = domain("t.test", Some(dir_b));
tenant_domain.member_tenant_id = Some(tenant);
admin
.registry_create_object_expect_err(tenant_domain)
.await
.assert_type(jmap_proto::error::set::SetErrorType::InvalidForeignKey);
let _ = (c, d);
test.temp_dir.delete();
}