From a635b490ec53c46426fc4e472fe6453c9c2be6ce Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 10:51:41 -0700 Subject: [PATCH] Per-domain directories: implementation status and the compat check (DIR-1 to DIR-32, test 20) per_domain_directory_compat, ignored, checks a copy of INBUXA's data has no directory, no server default and no domain with its own directory, as observed. The status names where the rules live, which suite covers each acceptance test and how far, what was settled from the code, and the known limits. SCIM's status notes its test 5 now passes. --- docs/spec/features/per-domain-directories.md | 53 ++++++++++++++++++++ docs/spec/features/scim.md | 4 +- tests/src/directory/per_domain.rs | 51 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/spec/features/per-domain-directories.md b/docs/spec/features/per-domain-directories.md index 5fde141..3b6bf6b 100644 --- a/docs/spec/features/per-domain-directories.md +++ b/docs/spec/features/per-domain-directories.md @@ -535,6 +535,59 @@ data. they did and doesn't need to. The rules they may have enforced (quota, SCIM authority, tenant checks) are specified here from other sources. +## Implementation status + +Built 2026-09-19 from this spec, clean-room, under the multi-tenancy +hand-off brief's rules. The two lookups every caller goes through now +honor the domain (`crates/common/src/auth/authentication.rs`), with the +rules around them in `cache/directory.rs` (synchronization), +`network/mta.rs` (recipients), `crates/directory/src/core/config.rs` +(building) and `crates/jmap/src/registry/set.rs` (reloading); each change is +marked `inbuxa:`. + +- **DIR-1 to DIR-32:** built, with the limits below. DIR-2, DIR-8, DIR-12, + DIR-13 and DIR-25 to DIR-30 were already in the AGPL code and now follow + the domain. +- **Tests.** `directory::per_domain::per_domain_directory_tests` covers + tests 1, 3, 4, 6, 7, 9, 11, 15 and 19, and DIR-20 and DIR-21, over SQL + directories on SQLite files, with no container. `directory_tests` runs a + new `oidc` module in place of the removed one, against Keycloak: tests 5, + 8, 12, 13, 14, 16 and 17 in part (below). SCIM's acceptance test 5 + (`scim_oidc_tests`) now runs and passes. +- **Test 20 (compat)** is `per_domain_directory_compat`, ignored, and unrun + until a copy of INBUXA's data is provided. It checks observed 1. +- **Not exercised, or only in part:** + - Test 2 and test 9 use an SQL directory that can't open instead of a + stopped LDAP server, and test 18 (a stopped provider, and the sign-in + ban) isn't run: the Keycloak container is shared. + - Test 10 needs a second provider, and test 12's later sign-ins (an empty + groups claim clearing groups, a missing one keeping them) need changes + to Keycloak users; neither is run. + - Test 13 is checked through synchronization itself, since the realm's + users aren't on the tenant's domain. Test 14 reuses an account an + administrator made, not one from an earlier LDAP directory. + - Test 16 checks `HS256`, an unknown `kid`, another issuer and an expired + token. Keycloak grants every required scope whatever is asked, so the + missing-scope refusal isn't reached; audience and key rotation aren't + run. Test 17 checks password sign-in and a malformed token; an opaque + token and `usernameDomain` aren't run. + - DIR-22's rule that `Authentication.directoryId` names a server-level + directory, and DIR-24 (a tenant administrator setting its own domains' + directory), aren't tested. +- **Settled from the code, not a change of intent:** + - A write to `x:Directory` or `Authentication` reloads the directories + at once, on every node (DIR-17). Settings otherwise apply on an + explicit reload. + - A directory that fails to open is a build warning, not an error + (DIR-21, open question 6): before, one error stopped every later + reload from applying. + - A server default naming no directory is unavailable, like a domain's + (DIR-5); it used to mean the internal directory. +- **Known limits, not requirements of this spec:** + - An SQL directory on a SQLite path that can't be opened holds the reload, + and the request that caused it, for the pool's 30-second connection + timeout before it's marked unavailable. + ## Observed Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart diff --git a/docs/spec/features/scim.md b/docs/spec/features/scim.md index 398c37f..0cf1468 100644 --- a/docs/spec/features/scim.md +++ b/docs/spec/features/scim.md @@ -875,8 +875,8 @@ steps, the 8 replayed Okta, Keycloak and Entra payloads, and scim2-tester with errors only for its generated non-address `userName`. - **SCIM-1 to SCIM-61:** built, with the limits below. -- **Test 5** (`oidc.rs`) is `scim_oidc_tests`, ignored until per-domain - directories (feature 9) are built (Decision, SCIM-61). +- **Test 5** (`oidc.rs`) is `scim_oidc_tests`, and passes since per-domain + directories (feature 9) were built (Decision, SCIM-61). - **Test 31 (compat)** is `scim_compat`, ignored, and unrun until a copy of INBUXA's data is provided. It checks observed 1. - **Settled from the code, not a change of intent:** diff --git a/tests/src/directory/per_domain.rs b/tests/src/directory/per_domain.rs index 96a5d7a..90d2923 100644 --- a/tests/src/directory/per_domain.rs +++ b/tests/src/directory/per_domain.rs @@ -468,3 +468,54 @@ pub async fn per_domain_directory_tests() { let _ = (c, d); test.temp_dir.delete(); } + +/// Acceptance test 20 (compat): on a copy of INBUXA's data, every domain +/// signs in against the same source as before cutover. Observed 1: no +/// directory, no server default, and no domain with `directoryId` set, so +/// every domain stays on the internal directory. Any domain with one is +/// listed first. Run with `INBUXA_COMPAT_ADMIN` (`name:password`), +/// `NO_INSERT=1`, and the store's `TMPDIR`/`STORE` pointing at the copy. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn per_domain_directory_compat() { + let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN"); + assert!(std::env::var("NO_INSERT").is_ok(), "NO_INSERT must be set"); + let _test = TestServerBuilder::new("per_domain_directory_compat") + .await + .with_default_listeners() + .await + .build_with_opts(false) + .await; + let (name, secret) = admin.split_once(':').expect("name:password"); + let admin = Account::new( + Box::leak(name.to_string().into_boxed_str()), + Box::leak(secret.to_string().into_boxed_str()), + &[], + "Compat admin", + Id::from(u32::MAX), + ); + let domains = admin + .jmap_method_call("x:Domain/get", json!({"ids": null})) + .await; + let with_directory = domains + .list() + .iter() + .filter(|domain| !domain["directoryId"].is_null()) + .map(|domain| domain["name"].to_string()) + .collect::>(); + assert!( + with_directory.is_empty(), + "domains with their own directory, a cutover blocker: {with_directory:?}" + ); + let directories = admin + .jmap_method_call("x:Directory/get", json!({"ids": null})) + .await; + assert!(directories.list().is_empty(), "observed 1: no directory"); + let authentication = admin + .jmap_method_call("x:Authentication/get", json!({"ids": ["singleton"]})) + .await; + assert!( + authentication.list()[0]["directoryId"].is_null(), + "observed 1: no server default" + ); +}