Files
inbuxa-server/docs/spec/features/per-domain-directories.md
T
jcoffey-dev cee4fd6bc6 Specs: how to run the eight compat tests on a copy of INBUXA's data, and the statuses of tests 10, 11, 17 to 19
docs/spec/compat-tests.md lists each compat test, what it needs, what it
checks and what a failure means, and says plainly that they run against a
copy only, since the monitoring one purges the history it reads. The
scale-out and per-domain statuses record the acceptance tests that now
run.
2026-09-19 15:11:04 -07:00

34 KiB

Feature spec: per-domain directories and the OIDC directory

Status: draft, 2026-09-18. Feature 9, proposed for SPEC.md §4. It isn't in §4's table yet. Proposed row:

# Feature What an operator gets Notes
9 Per-domain directories Each domain can sign its users in against its own LDAP, SQL or OIDC directory, instead of the server's one default The OIDC directory itself is already AGPL and isn't a gap (see below). Tenants bring their own directories.

Provenance

Written for the clean room (SPEC.md §3). Sources, and nothing else:

Source License Used for
This repository: crates/directory, crates/common/src/auth, crates/common/src/cache, crates/common/src/network/mta.rs, crates/http/src/auth, crates/jmap/src/registry/mapping, tests/src/directory, tests/src/utils AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL (Enterprise-only code was stripped before commit) How directories are opened, chosen, queried and cached today; what the OIDC directory validates; what survives the strip
Registry schema: crates/registry/src/schema/*.rs and resources/schema/schema.json.gz As above Field shapes, defaults, descriptions, and which fields upstream flags "enterprise": true
Strip report docs/fork/strip-reports/v0.16.22.json (and .md) Ours Which files and how many snippets were removed, per file. Only names and counts were read
Stalwart documentation (website repo): auth/backend/oidc.md, auth/backend/index.md, install/directory.md, auth/scim/provisioning.md, auth/authorization/tenants.md, server/enterprise.md, ref/object/domain.md, ref/object/directory.md, 0.15 auth/backend/oidc.md and auth/principals/domain.md, and the edition comparison pages/compare.yml Unlicensed public documentation: facts used, prose not copied What upstream says is Enterprise, just-in-time provisioning rules, OIDC limits
OpenID Connect Core 1.0 and Discovery 1.0; RFC 7662; RFC 9068; RFC 7628; RFC 4511; RFC 8620 IETF / OpenID Foundation Token validation, introspection, OAUTHBEARER, LDAP, JMAP errors

No Enterprise-only file or snippet was used. The author is a fresh session that has never seen Enterprise code, and read the stripped tree only. No server, live or local, was probed for this spec. Where the sources above don't settle a behavior, this spec makes a Decision of its own, or lists it under "Open questions / to observe". Nothing is filled in by guessing what upstream code does.

What it is

A directory is where the server checks who someone is: the internal directory (accounts in the server's own store), or an external one (an LDAP server, an SQL database, or an OpenID Connect provider). Today one setting, x:Authentication.directoryId, picks a single external directory for the whole server, or none for the internal one.

Per-domain directories let each domain pick its own. One server can sign in corp.example users against the company's Active Directory, school.example users against a Keycloak realm, and example.net users against the internal directory. This matters most with tenants (Feature 1): each organization brings its own identity system.

Upstream ships per-domain directories only in its Enterprise Edition. The edition comparison lists "Per-domain directory backends" as Enterprise-only. inbuxa-server ships it to everybody.

Where the fork stands today

x:Domain.directoryId is stored, writable over JMAP, validated as a reference to an x:Directory, and copied into the domain cache (DomainCache.id_directory, crates/common/src/cache/principals.rs). The cache is already invalidated when it changes (cache/invalidate.rs). But nothing reads it. The two lookups every caller goes through, get_directory_for_domain and get_directory_for_cached_domain (crates/common/src/auth/authentication.rs, marked inbuxa:), return the server default for every domain.

So a domain carried over from an Enterprise install with directoryId set currently signs in against the wrong source: the server default, or the internal directory. Decision: INBUXA's cutover (SPEC.md §7) checks the copied data for any domain with directoryId set. If there is one, this feature is a cutover blocker.

Everything else the feature needs is already in the AGPL tree, and already routes through those two lookups: sign-in, bearer-token routing, recipient lookup, account discovery, the PACC DNS record, and the refusal to change passwords on external accounts. Rebuilding the feature mostly means making the two lookups honor the domain, then adding the rules below.

Is the OIDC directory a gap?

No. Signing in against an OIDC provider is AGPL in upstream, and works in the stripped tree as the server's default directory. Only two things around it are Enterprise: pointing a single domain at it (this feature), and giving it a tenant (OidcDirectory.memberTenantId, Feature 1). The integration test is gated only because the test file itself was Enterprise-only and was removed.

Evidence:

  1. Headers. crates/directory/src/backend/oidc/mod.rs, config.rs and lookup.rs all carry AGPL-3.0-only OR LicenseRef-SEL. So do core/dispatch.rs, core/config.rs, core/sasl.rs and lib.rs.
  2. Nothing stripped from the directory crate. The strip report lists no removed file and no removed snippet anywhere under crates/directory. Its enterprise Cargo feature is declared in Cargo.toml and gates no code.
  3. Schema flags. The x:Directory object and its Oidc variant aren't flagged. Of x:OidcDirectory's nine fields only memberTenantId is flagged, the same as on the LDAP and SQL variants. x:Authentication.directoryId, which picks the default directory, isn't flagged. x:Domain.directoryId is.
  4. It compiles and is wired in. The stripped build (SPEC.md §2.2b) opens OIDC directories in Directories::build and uses one as the default when Authentication.directoryId names it. The bearer path in authentication.rs (routing, then the external directory, then the server's own tokens) sits outside any removed snippet. So do /api/discover and the PACC record.
  5. Ungated AGPL tests use it. tests/src/directory/discovery.rs runs a Keycloak container and asserts that the default directory is the OIDC provider. tests/src/directory/unavailable.rs checks an unreachable OIDC directory. Neither is behind pending-rebuild.
  6. What's gated, and why. tests/src/directory/oidc.rs was a whole file licensed LicenseRef-SEL alone. It was removed, which left pub mod oidc; dangling (strip report, "dangling mods") and the oidc::test() call gated. What it tested can't be known without reading it, and it wasn't read. Nothing shows OIDC sign-in itself to be Enterprise.
  7. Public docs. The OIDC backend page has no Enterprise marker. The comparison page lists "Third-party OIDC providers" and "OpenID Connect" in both editions, and "Per-domain directory backends" as Enterprise-only. The reference page for Domain.directoryId marks it Enterprise.

What follows from this: the OIDC directory needs no rebuild. It does need a new integration test, written from this spec (tests 12 to 17 below), to replace the lost one. The oidc::test() gate comes off when that lands.

Data model

Unchanged from upstream, so existing data opens as it is (SPEC.md §7).

On x:Domain

Field Type Meaning
directoryId Id<x:Directory>, nullable, mutable The directory this domain's accounts sign in against. Flagged Enterprise upstream; ordinary here

The schema describes null as "use the internal directory". This spec reads null as "use the server default" instead (DIR-1), because that's what every domain does today, in both editions, whenever a default is set.

On x:Authentication (singleton)

Field Type Meaning
directoryId Id<x:Directory>, nullable The server default directory. Null: the internal directory

x:Directory, three variants

Permission prefix sysDirectory. The variants are Ldap (x:LdapDirectory), Sql (x:SqlDirectory) and Oidc (x:OidcDirectory). Each variant carries memberTenantId, a nullable Id<x:Tenant> flagged Enterprise upstream. Feature 1 covers that field. TenantStorageQuota has maxDirectories.

x:OidcDirectory

Field Type, default Meaning
description string Label
issuerUrl URI The provider's issuer. Discovery is fetched from <issuerUrl>/.well-known/openid-configuration
requireAudience string, nullable If set, a token's aud must include it
requireScopes set of strings, default openid, email Every listed scope must be in the token
claimUsername string, default preferred_username Claim that names the account
usernameDomain string, nullable Appended as @domain when the username claim has no @. Also appended to bare group names
claimName string, nullable, default name Claim for the display name
claimGroups string, nullable Claim for group memberships
memberTenantId Id<x:Tenant>, nullable Tenant (Feature 1)

The LDAP and SQL variants are unchanged, and this feature adds nothing to them.

Required behavior

Each requirement has an ID, and tests name the IDs they check. "Effective directory" means the directory DIR-1 picks for a domain.

Choosing the directory

  • DIR-1. A domain's effective directory is the one its directoryId names. If that is null, it's the server default (Authentication.directoryId). If that is null too, it's the internal directory. Decision: null means the server default, not the internal directory as the schema text says. Every existing install with a default external directory relies on this reading, since that is how every domain behaves before this feature exists.
  • DIR-2. Which domain decides:
    • Password sign-in: the domain of the address signing in. With impersonation (target%master), it's the master user's domain. A bare name gets the default domain, as now.
    • Bearer token: the domain of the user the client names (the SASL a= authorization identity, or the HTTP username). If there is none, the domain of the first address found in the token's claims (email, preferred_username, upn, as the code reads them now). If there is none of those either, the server default. The claims are read unverified, and only to choose the directory. The chosen directory then verifies the token (DIR-26), and DIR-6 checks the result.
    • Recipient: the recipient's domain.
  • DIR-3. Local credentials are checked before any directory, as now: the recovery admin, app passwords and API keys. They belong to the server, not the directory, and they're how users of clients without OAUTHBEARER reach an OIDC domain (public OIDC docs). See open question 3.
  • DIR-4. No fallback. A domain whose effective directory is external signs in against that directory and nothing else. When it says no, is unreachable, or failed to start, sign-in fails. It never falls back to the server default, another directory, or a password held in the internal directory. Decision (settled for this spec). The AGPL test unavailable.rs already requires this for password sign-in against an unavailable directory.
  • DIR-5. A directoryId that names no directory the server could build (deleted, or never opened) makes the domain's directory unavailable: DIR-4 applies. It is never read as "no directory". Today the cached-domain lookup returns nothing in that case, which would mean internal sign-in. It must not.
  • DIR-6. A directory speaks only for its own domains. The account a directory returns must be on a domain whose effective directory is that same directory. Otherwise sign-in fails, and no account is created or updated. Decision. Without it, a tenant's identity provider could sign someone in as, or create, an account on another tenant's domain. The same rule filters what synchronization accepts. An alias or group address on a domain served by a different directory is dropped, and a warning event is emitted. Upstream's code already drops aliases on other tenants' domains. This goes further.
  • DIR-7. The token must match the named user. When a bearer client names a user (DIR-2) and the token resolves to a different account, sign-in fails, unless the named address is one of the account's aliases and the account holds authenticateWithAlias, the same rule as password sign-in. Decision. Today the bearer path doesn't compare them.
  • DIR-8. Tokens the server issued itself (its own OAuth provider) are still accepted after the external directory rejects a bearer token, as now. They aren't another directory. For a domain with its own directory, the server's sign-in page authenticates through that directory (DIR-4), so the server only issues such tokens on its say-so.

Recipients

  • DIR-9. Recipient lookup asks the domain's effective directory when it can look recipients up (LDAP, SQL). A positive answer synchronizes the account or group (DIR-14) before the message is accepted. The directory is the authority for accounts on that domain: an account in the internal store that the directory doesn't know isn't a valid recipient. Mailing lists and the catch-all still resolve from the internal store. That is what the AGPL code does today for the server default, applied per domain.
  • DIR-10. An OIDC domain has no recipient lookup, because OIDC offers none. The internal store decides. An account that has never signed in doesn't exist unless an administrator created it first (public docs). Administrators may create accounts on an OIDC domain, without a password (DIR-13).
  • DIR-11. If the directory can't be reached during a recipient lookup, the answer is a temporary failure (4xx). The message is never accepted, and the lookup never falls back to the internal store. Decision, to be checked by test 9.

Discovery

  • DIR-12. GET /api/discover/{address} returns the discovery document of the address's domain's provider when its effective directory is OIDC. Otherwise it returns the server's own OAuth metadata. The recovery admin always gets the server's own. The PACC DNS record for a domain carries the domain's provider issuer when its effective directory is OIDC, and the server's own URL otherwise. Both work this way today, but only for the server default.

External accounts

  • DIR-13. On an account whose domain's effective directory is external, setting or changing the password or its OTP secret is refused with forbidden, and /api/account leaves out sysAccountPasswordGet and sysAccountPasswordUpdate. That's today's behavior, decided per domain. App passwords and API keys stay allowed.

Creating and updating accounts (just-in-time)

  • DIR-14. After a successful sign-in, or a positive recipient answer, the server synchronizes the directory's record into a local account, matched by address (local part and domain):
    • Missing account: created with the local part as name, the domain, the domain's tenant (MT-7), the User roles, the description, and the aliases and groups (as filtered by DIR-6). It also gets a password credential when the directory supplies a secret (LDAP, SQL; never OIDC). Missing groups are created on the same terms.
    • Existing account: the description is overwritten when the directory supplies a different one. Aliases are added and never removed. Groups are replaced when the directory reports them: a missing claim leaves them alone, while an empty list clears them. The secret is updated. Quotas, roles, permissions and settings are local and never touched.
    • The domain must already exist and be enabled. A directory never creates a domain.
  • DIR-15. Creating an account or group this way counts against the tenant's maxAccounts and maxGroups (MT-17). Over the limit, sign-in or delivery fails, and limit.tenant-quota is emitted. Decision: a directory is not a way around a tenant's limits.
  • DIR-16. Synchronization never deletes or suspends an account (public docs). On a domain with allowScimProvisioning set, Feature 7's spec governs instead: there, synchronization only reads, and never creates an account.

Changing a domain's directory

  • DIR-17. A change to Domain.directoryId, Authentication.directoryId or any x:Directory takes effect on the next request, without a restart. Sessions already signed in keep running until they end. Tokens the server has already issued aren't revoked (see open question 5).
  • DIR-18. Changing a domain's directory never deletes, moves or changes any account, message, alias, group, app password or API key. When the new directory first vouches for an address, it updates the account already there (DIR-14) rather than making a second one.
  • DIR-19. Accounts the new source doesn't know can't sign in through it (app passwords and API keys still work, DIR-3). On an LDAP or SQL domain they also stop receiving mail (DIR-9). The admin front ends say how many accounts that is before the change is saved.
  • DIR-20. When a domain moves from an external directory to the internal one, password credentials synchronized from the directory stay and keep working. Decision: it lets an operator migrate a domain off LDAP or SQL without resetting every password. The front ends warn that each password is whatever the directory last supplied, and that accounts disabled in the directory can sign in again. Accounts that came from OIDC have no password. They need one set, or an app password.
  • DIR-21. Writes are checked:
    • directoryId must name an existing x:Directory (the registry's foreign-key check, as today).
    • A directory still named by a domain or by Authentication can't be deleted: objectIsLinked, listing what refers to it.
    • A directory that fails to open is logged against its id and becomes unavailable. Every other directory, and the rest of the reload, carries on (open question 6).

Tenancy

  • DIR-22. Directories follow Feature 1:
    • A directory with memberTenantId belongs to that tenant. It is visible only to the tenant (MT-1), created by its administrator, and counted against maxDirectories.
    • A domain can name only a directory in its own tenant, and a server-level domain only a server-level directory. Otherwise the write is refused with invalidForeignKey, naming directoryId (MT-3).
    • Authentication.directoryId must name a server-level directory. Decision: the server default is server infrastructure.
  • DIR-23. A tenant domain whose directoryId is null uses the server default, like any other domain (DIR-1). Decision, for compatibility, and open question 7. MT-3 is still met: the domain links to nothing, and DIR-6 means the server default can't create accounts in any tenant whose domains point elsewhere.
  • DIR-24. A tenant administrator can set directoryId on its own tenant's domains, to its own tenant's directories. It can't change Authentication.

The OIDC directory

The AGPL code does all of this today. It's stated here so the new tests check it and the fork keeps it.

  • DIR-25. Opening.
    • The server fetches the discovery document. Its issuer must equal issuerUrl, ignoring a trailing slash. A mismatch is a configuration error.
    • It then fetches the JWKS from jwks_uri.
    • Network and provider errors are retried every 3 seconds for up to 30. Configuration errors aren't retried.
    • A directory that doesn't open is unavailable (DIR-4, DIR-5).
    • A required scope or configured claim that the provider doesn't advertise gives a warning, not an error.
  • DIR-26. JWT access tokens.
    • HS* algorithms are refused, and HMAC keys in the JWKS are skipped. RSA (RS and PS), EC P-256 and P-384, and EdDSA are accepted.
    • The key is found by kid. An unknown kid refetches the JWKS, at most once every 300 seconds. With no kid, every key is tried.
    • iss must equal the discovery document's issuer.
    • exp is checked, with 60 seconds of leeway.
    • If requireAudience is set, aud must be present and include it.
    • Every requireScopes entry must appear in scope, which may be a space-separated string or an array.
  • DIR-27. Opaque tokens. A token that isn't a JWT is sent to the provider's userinfo endpoint (OIDC Core §5.3). 401 or 403 means sign-in fails. Token introspection (RFC 7662) isn't used (public docs), and no audience or scope check is possible on this path (open question 2).
  • DIR-28. Identity.
    • The account address is the claimUsername claim if it contains @. Otherwise it is that claim plus @usernameDomain, or failing that the email claim. With none of these, sign-in fails.
    • When the JWT lacks the address, or a configured name or groups claim, the userinfo response fills the gaps. The JWT's own claims win.
    • Group names without @ get @usernameDomain.
  • DIR-29. An OIDC directory refuses password sign-in. Under DIR-4, a password is never tried anywhere else for that domain. App passwords (DIR-3) remain.
  • DIR-30. Failure classes. An invalid or rejected token is an authentication failure: it counts toward the sign-in ban. A network, provider or configuration fault is an error: it doesn't count, and the client gets a temporary failure where the protocol has one. The same split applies to LDAP and SQL (an unreachable server is an error, not a wrong password).

Caching

  • DIR-31. Only these are cached:
    • the domain cache, including its directory id, which is invalidated when directoryId changes;
    • the built directories (connection pools, OIDC discovery and keys), rebuilt when any x:Directory or Authentication changes;
    • each OIDC directory's JWKS, refreshed under DIR-26.
  • DIR-32. Directory answers aren't cached. Every password sign-in and every bearer token without a server-issued match asks the directory. Decision: no positive cache, which would keep a disabled user in, and no negative cache, which would keep a fixed user out. Recipient answers materialize local accounts (DIR-14). Those accounts are what later lookups find.

Interfaces

  • Existing, unchanged: x:Domain/get and /set with directoryId; x:Directory/*; x:Authentication; GET /api/discover/{address}; /api/account; SASL PLAIN, LOGIN, OAUTHBEARER and XOAUTH2; HTTP Basic and Bearer. What changes is behavior: which directory answers.
  • Errors: RFC 8620 SetError types: invalidForeignKey (DIR-22), objectIsLinked (DIR-21), forbidden (DIR-13), overQuota (DIR-15, surfaced as a sign-in or delivery failure). Protocols keep their own failure codes, temporary for DIR-11 and DIR-30.
  • Events: existing auth.* events. DIR-6 drops and DIR-5 dangling ids emit auth.warning with the domain and directory id.
  • New: none. The DIR-19 count comes from querying accounts on the domain, which the front ends can already do.

ihasmail changes

These go in ihasmail-inbuxa, the INBUXA fork of ihasmail, never in public ihasmail, which stays Stalwart-facing (SPEC.md §5).

  • Domain editor: a directory picker offering "server default" and the directories the admin can see (its tenant's, for a tenant admin, DIR-22). The directory objects themselves are edited in INBUXA Admin, which ihasmail links to (SPEC.md §5.4).
  • Before saving a directory change: warn with the DIR-19 count, and, when moving to the internal directory, the DIR-20 warning.
  • Settings: hide password and two-factor changes when /api/account lacks sysAccountPasswordUpdate (DIR-13), and point users to app passwords. On an OIDC domain, say that the password is managed by the organization's sign-in provider.
  • Sign-in: when the address's domain has its own OIDC provider (/api/discover, DIR-12), send the user there, not to a password form. How this fits the OAuth contract belongs in contract.md.
  • Errors: show DIR-15 quota refusals and DIR-30 outages in plain words ("your organization's sign-in service is unreachable"), not as a wrong password.

INBUXA Admin builds its forms from the schema, so it shows directoryId with no work beyond the edition gating it already removes.

Acceptance tests

Every test runs against inbuxa-server built with no Enterprise code. Tests needing a directory use the containers the AGPL suite already has (tests/src/utils/containers.rs): OpenLDAP (osixia/openldap, fixtures in tests/docker/ldap) and Keycloak (realm in tests/docker/keycloak). The per-domain tests need a second Keycloak realm, or a Dex container, as a second provider. The OIDC tests go in a new module that replaces the gated oidc::test() call. Marked (compat): also run against a copy of INBUXA's data.

  1. Domain A on LDAP, domain B on SQL, domain C on none, and no server default: A and B sign in against their own directories, C against the internal one. With SQL made the server default, C signs in against SQL, and A is unchanged (DIR-1). LDAP, SQL (SQLite).
  2. A has LDAP and the LDAP server is stopped: A's password sign-in fails, even for a user with an internal password on A. B is unaffected (DIR-4). LDAP.
  3. directoryId naming a directory that failed to open: sign-in fails and never reaches the internal store (DIR-5). None.
  4. A's LDAP returns an account on domain B: refused, and nothing is created (DIR-6). Aliases and groups on B are dropped with a warning. LDAP.
  5. OAUTHBEARER naming alice@a with a valid token for bob@a: refused (DIR-7). Keycloak.
  6. App password on an LDAP-backed domain works while LDAP is stopped (DIR-3). LDAP.
  7. Mail to an LDAP user who has never signed in is accepted and creates the account (DIR-9, DIR-14). Mail to an internal-only account on that domain is rejected. LDAP.
  8. Mail to an OIDC domain address that never signed in is rejected. After a pre-created account exists, it's accepted (DIR-10). Keycloak.
  9. Mail to an LDAP domain with LDAP stopped gets a 4xx (DIR-11). LDAP.
  10. /api/discover for a user on A (provider 1) and a user on B (provider 2) returns each provider's document. The PACC record differs likewise (DIR-12). Two Keycloak realms.
  11. A password change on an external-directory account: forbidden. It's allowed again after the domain moves to the internal directory (DIR-13, DIR-20). LDAP.
  12. OIDC first sign-in creates the account with name, groups and tenant. Second sign-in with an empty groups claim clears the groups. With no groups claim, they stay (DIR-14). Keycloak.
  13. Tenant at maxAccounts: OIDC first sign-in fails with limit.tenant-quota (DIR-15). Keycloak.
  14. Moving a domain from LDAP to OIDC keeps every account and message, and the first OIDC sign-in reuses the existing account (DIR-18). LDAP, Keycloak.
  15. Deleting a directory a domain uses: objectIsLinked (DIR-21). A tenant domain naming a server-level directory: invalidForeignKey (DIR-22). None.
  16. JWT validation: wrong issuer, wrong audience, a missing required scope, expired beyond 60 seconds, HS256, and an unknown kid are each refused. A rotated key is picked up (DIR-26). Keycloak.
  17. Opaque token accepted through userinfo, and one revoked at the provider is refused (DIR-27). Username without @ plus usernameDomain resolves (DIR-28). Password sign-in to an OIDC domain is refused (DIR-29). Keycloak.
  18. Provider stopped: sign-in fails as a temporary error, and 20 attempts from one IP don't trigger the sign-in ban. 20 bad tokens do (DIR-30). Keycloak.
  19. Changing directoryId takes effect on the next sign-in with no restart (DIR-17, DIR-31). LDAP.
  20. (compat) Every domain in INBUXA's data signs in against the same source as before cutover. Any domain with directoryId set is listed first (see "Where the fork stands today").

Open questions / to observe

  1. Explicit internal directory. Under DIR-1, a domain can't opt out of a server default and use the internal directory. The data model has no way to say so. Needed? If it is, it's a fork-namespace field, not a change to upstream's.
  2. Opaque tokens and audience. On the userinfo path (DIR-27), requireAudience and requireScopes can't be enforced. Should a directory with either set refuse opaque tokens, or offer RFC 7662 introspection (which needs client credentials the schema has no field for)? The docs name requireAudience's default as stalwart, and the schema has no default. Observe which one a new directory gets.
  3. Local credentials outlive the directory. App passwords and API keys keep working for a user disabled in the directory (DIR-3). Should synchronization, or a failed directory sign-in, suspend them?
  4. ID tokens as access tokens. DIR-26 doesn't check typ (RFC 9068 at+jwt). An ID token whose aud equals requireAudience would pass. Check typ when present, or advise a distinct audience?
  5. Revocation on a directory change. DIR-17 leaves server-issued tokens valid. That ties to the contract's token revocation (SPEC.md §5.2).
  6. One broken directory. When one directory fails to open, the build error is recorded, and the non-certificate reload path applies only when there were no errors (cache/reload.rs). Observe on a local build whether one bad directory blocks unrelated setting changes. DIR-21 requires it doesn't.
  7. Tenant domains and the server default. DIR-23 lets a tenant's domain with no directory use the server default. Alternative: tenant domains default to the internal directory. Settle once INBUXA's data shows whether any tenant domain relies on a default.
  8. Which snippets in cache/directory.rs matter. The strip report shows 6 snippets removed from that file, 2 from authentication.rs, 2 from auth/mod.rs and 2 from cache/principals.rs. This spec doesn't say what 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, whose container now imports a second realm: tests 5, 8, 10, 12, 13, 14, 16, 17 and 18, the last two 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.
    • Test 12's later sign-ins (an empty groups claim clearing groups, a missing one keeping them) need changes to Keycloak users, and aren't 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 that the provider accepts, and usernameDomain, aren't run. Test 18 stops the provider and checks an outage doesn't ban the client, and that bad tokens do; it doesn't measure the failure's latency.
    • 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.
    • A token the OIDC directory refuses is an authentication failure, so it counts toward the sign-in ban (DIR-30); it used to be an error, which counts toward nothing.
  • 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 0.16.22), read-only, as a server-level administrator and the throwaway test account. No upstream code was read.

  1. The cutover check passes. INBUXA has no external directory (x:Directory is empty), x:Authentication.directoryId is null, and all nine domains have directoryId null. The fork ignoring directoryId today changes nothing for INBUXA.
  2. Tenant domains (open question 7). INBUXA has no tenants, so no tenant domain relies on a default. DIR-23 stands as written.

Open question 2 (the requireAudience default) needs a directory to be created, so it stays open.