34. Per-Domain Assignment Drivers
Date: 2026-09-07
Status
Proposed.
Extends the domain configuration for the identity driver.
Context
A domain configuration already selects a domain’s identity backend.
IdentityService holds every registered IdentityBackend by name plus a
DomainConfigResolver, and driver_for resolves the domain’s stored
identity/driver, dispatching to the named backend and falling back to the
global [identity] driver when per-domain drivers are off, the domain is
unknown, no driver is stored, or resolution fails.
The assignment provider has nothing equivalent. AssignmentService
(crates/core/src/assignment/service.rs) holds exactly one
backend_driver: Arc<dyn AssignmentBackend>, resolved once in
AssignmentService::new from [assignment] driver. Since ADR 0033, a
deployment must therefore choose sql or openfga for the entire cloud.
There is no way to:
- migrate one domain at a time onto OpenFGA and keep the rest on SQL;
- let a single tenant whose authorization already lives in a central OpenFGA store keep it there while the rest of the cloud stays on local tables;
- evaluate the OpenFGA driver against production traffic in one domain without betting every domain’s token issuance on it — a live risk, given ADR 0033’s warning that a deployment without a group-membership sync silently issues tokens missing every group-derived role.
Why the identity pattern does not transfer unchanged
An identity entity belongs to exactly one domain, so driver_for_user has one
question to answer. A role assignment has two entities — an actor (user or
group) and a target (project, domain or system) — and they need not share a
domain: a user in domain A can hold a role on a project in domain B.
Dispatch must therefore pick one of them as the key, and the choice is not symmetric. That is the central decision of this ADR.
Two things a domain needs to move onto OpenFGA
Moving a domain off the global driver takes two distinct pieces of information:
- a driver name — which registered backend serves the domain. Small, holds no secret, safe to change at runtime.
- a driver configuration — for OpenFGA the store id, model id, API URL and
bearer token; for SQL nothing beyond the global
[database]. The endpoint and the bearer token are trust anchors for every authorization decision the domain makes.
This ADR routes the first through the domain configuration API and keeps the second in server configuration. §3 and §4 give the reasoning; the short version is that an API-writable driver configuration is a role-minting escalation (§6).
The relevant shape of the existing code
AssignmentApi(crates/core/src/assignment/provider_api.rs) has three methods:create_grant,list_role_assignments,revoke_grant.AssignmentBackend(crates/core/src/assignment/backend.rs) addscheck_grant, which has no production caller.AssignmentCreateandAssignmentboth carrytarget_idand anAssignmentType(UserProject,GroupDomain,UserSystem, …) that names the target kind. Both write paths thus always know their target.RoleAssignmentListParameterscarriesuser_id/group_id(actors) anddomain_id/project_id/system_id(targets), all optional. A fully empty value is legal.- The
DomainConfigResolver(crates/core/src/domain_config/resolver.rs) already exists onProvider, exposed asget_domain_config_resolver, and overlays the database source onto the file source (the database wins, option by option) into a rawDomainConfig.IdentityServicebuilds its own instance, gated on[identity] domain_specific_drivers_enabled;DomainConfigResolver::newdecides which of the two sources it holds by reading the[identity]switches directly. Per-domain assignment consults a resolver for a single value,assignment/driver. - Domain-configurable option names are whitelisted explicitly in
crates/core-types/src/domain_config/option.rs— today only theidentityandldapgroups.
Decision
1. Dispatch keys on the target, never on the actor
The driver serving an assignment is chosen from the target:
| Target kind | Domain used for dispatch |
|---|---|
| domain | the target domain itself |
| project | the project’s domain_id, via get_project |
| system | none — always the default driver |
The decisive reason is not convenience but an invariant: the target-keyed partition is closed under Keystone’s assignment hierarchy. Every expansion a driver performs stays inside the target’s domain:
- A project’s ancestors are always in the same domain as the project, so the SQL
driver’s
get_project_parentswalk never leaves the domain. - A domain-level
inherited: truegrant applies only to that domain’s projects. - Implied-role expansion rewrites the role, not the target.
Actor-keyed dispatch would violate this. A cross-domain grant (actor in A, project in B) would be stored in A’s driver, but B’s project-tree walk and B’s target-scoped listings query B’s driver and would not find it. The assignment would be simultaneously present and absent depending on which side asked. Target-keyed dispatch has no such split: whoever asks about a target asks the one driver that owns it.
A second consequence follows for free: because the two write paths always carry
target_id and AssignmentType, create_grant and revoke_grant dispatch
unambiguously with no extra lookup beyond the project→domain resolution.
System-scoped assignments have no owning domain and stay on the default driver. This is deliberate rather than a fallback: system roles are the most privileged scope in the deployment, and keeping them out of any domain-selected backend bounds the blast radius of §6.
2. Per-provider dispatch; resolver sources gated in [domain_config]
Two orthogonal decisions, deliberately kept apart.
Per-provider dispatch. A new switch in crates/config/src/assignment.rs,
domain_specific_drivers_enabled, defaulting to false, independent of
[identity] domain_specific_drivers_enabled. An operator running per-domain
LDAP identity must not silently acquire per-domain assignment routing — the two
are unrelated concerns with very different security profiles, and an existing
deployment that already sets the identity switch must not change behaviour on
upgrade. When the switch is off, AssignmentService keeps a None resolver and
every operation goes to the global driver, exactly as today.
Resolver sources. Which of the two sources a DomainConfigResolver holds —
the fs source (per-domain files under [identity] domain_config_dir) and the
sql source (the domain_config table) — is shared infrastructure, not a
per-provider concern: both sources carry every group, and each consumer filters
to its own (resolve_identity, resolve_assignment_driver_name). Today
DomainConfigResolver::new reads the [identity] switches directly, which is
why [assignment] domain_specific_drivers_enabled on its own would resolve
every domain to the empty configuration unless an [identity] source were also
enabled. This ADR moves that gate to its own section rather than leave the
dependency implicit:
[domain_config]
from_files = false # hold the fs source
from_database = true # hold the sql source
Config blocks in this ADR are rendered as TOML for readability. The primary
keystone.conf is INI; there these are plain INI sections (the config crate
splits dotted section headers, as [auth_plugin.<name>] already relies on), and
a KEYSTONE_SITE_VARS_FILE overlay may equally be TOML. The same holds for
every block below.
- Both fields are
Option<bool>internally.DomainConfigResolver::newreads the effective valuefrom_files.unwrap_or([identity] domain_specific_drivers_enabled)andfrom_database.unwrap_or([identity] domain_configurations_from_database). A deployment that sets only the old[identity]keys is unchanged, defaults included. - When a
[domain_config]key is set explicitly it wins; if a deprecated[identity]key is also set to a different value, construction logs oneWARNnaming the deprecated key and the winner. [identity] domain_configurations_from_databasebecomes a deprecated alias for[domain_config] from_database.[identity] domain_specific_drivers_enabledis only partially superseded — it keeps its second job, turning on identity per-domain dispatch — so it is not deprecated, only its source-gating side effect moves.[identity] domain_config_dir(the fs driver’s path) stays in[identity]. Both are residual warts, smaller than the one they replace and now explicit.
Per-provider resolver instances. IdentityService keeps its own
Option<Arc<DomainConfigResolver>>, built at startup and restart-only, gated on
[identity] domain_specific_drivers_enabled. AssignmentService holds its own,
inside the ArcSwap bundle of §9, gated on
[assignment] domain_specific_drivers_enabled. Both instances read the same
[domain_config] gate and wrap the same shared fs / sql backend Arcs —
the split exists only so a §9 reload can swap assignment’s resolver without
disturbing identity’s restart-only path. Provider::get_domain_config_resolver
is retained, built from the same effective gate; removing it is not this ADR’s
job.
The honest residual: source gating ([domain_config]) and dispatch
([assignment] domain_specific_drivers_enabled) are two knobs and both are
required. That is strictly better than today’s silent dependence on an
[identity] key, because the dependency now has its own name and its own
documentation.
3. The domain configuration API carries a driver name, not a configuration
One new domain-config group, assignment, with exactly one option. In
crates/core-types/src/domain_config/option.rs:
DomainConfigGroupName::Assignment, extendingALL,as_strandFromStr.ASSIGNMENT_WHITELISTED_OPTIONS = &["driver"],ASSIGNMENT_SENSITIVE_OPTIONS = &[].
There is no openfga domain-config group. A domain’s stored configuration
names the driver it wants — nothing else. list_limit is deliberately excluded:
it is a per-provider tuning knob ([assignment] list_limit), not a per-domain
concern, and a domain that raises it would shift load onto shared driver
instances.
driver is validated at write time against the set of names the operator has
made bindable (§4): sql, the driver of the global [assignment] section, and
every driver named by an [assignment.backends.*] block. A write naming any
other value — including openfga when the deployment configures no OpenFGA
backend anywhere — is rejected by the config API, not stored and later ignored.
DomainConfig gains resolve_assignment_driver_name(&Config) -> Option<String>
beside the existing resolve_identity / resolve_ldap. There is deliberately
no resolve_openfga: the API layer never constructs a driver configuration.
Why the configuration stays out of the API. Per
doc/src/contributor/security-model.md, a domain’s assignment driver
configuration is a trust anchor for every token issued in that domain (§6). An
API-writable configuration would:
- carry the OpenFGA bearer token and any mTLS material through the config API
and into the shared
domain_configtable, theSerialize/Debugredaction the only thing between it and a log line; - let whoever holds the config-API policy repoint a domain at an OpenFGA store they control and mint arbitrary roles there — the endpoint itself, not merely the choice among vetted endpoints, would be attacker-supplied;
- add a per-option whitelist (fourteen
[openfga]keys) that has to track the driver struct as it grows, each new key one review slip from being domain-writable.
Keeping the configuration in server config removes all three. The API caller chooses among backends the operator has provisioned; it cannot define one.
4. Per-domain driver parameters live in server configuration
crates/config/src/assignment.rs gains two maps: named backend blocks that
each hold one driver configuration, and a domain → backend table.
[assignment]
driver = "sql" # global default; system + fallback
domain_specific_drivers_enabled = true
# A named driver configuration. Any number of domains may point at it.
[assignment.backends.central_fga]
driver = "openfga"
api_url = "https://openfga.internal:8080"
store_id = "01JQ..."
model_id = "01JQ..."
api_key = "..." # secret; never leaves the host
# ... the full [openfga] option set, typed, no whitelist
# Map a domain to a backend block. No per-domain parameters.
[assignment.domains]
"1111...1111" = "central_fga"
"2222...2222" = "central_fga"
Rendered as TOML; in keystone.conf these are plain INI sections
([assignment.backends.central_fga], [assignment.domains]), as noted in §2.
- Both maps are typed in
crates/configlike the rest of the file. There is no whitelist and no redaction plumbing because they never cross a process boundary. - Multiple domains share one backend by naming it. Two domains mapped to
central_fgaresolve to the sameAssignmentBackendinstance — one OpenFGA client, one connection pool — with no configuration repeated. Instances are keyed by backend name, not by domain. - A backend block’s
drivermust equal the mapped domains’ API-storedassignment/driverbinding (§3). Asqlbackend block is allowed but rarely needed: an SQL-backed domain with no[assignment.domains]entry already shares the global[database]instance (§7). - The global
[assignment] driver— and, foropenfga, the global[openfga]section — still servessystemtargets, unconfigured domains, and every resolution fallback. A domain that wants the global OpenFGA store bindsopenfgawith no[assignment.domains]entry and shares the default instance; nothing is repeated for that case either.
AssignmentService therefore keeps, in addition to the name→backend registry
that plugin_manager provides:
- a map of backend name → resolved
Arc<dyn AssignmentBackend>, built from the[assignment.backends.<name>]block the first time a bound domain resolves to it and reused by every other domain that names it; - the global driver, used for
systemtargets, for domains with no binding or no mapping, and as the fallback whenever resolution fails.
Joining the binding to the parameters. Resolution for a domain is:
- Resolve
assignment/driverthrough theDomainConfigResolver(§3). Absent → global driver. - Name present → look up the domain in
[assignment.domains].- Mapped to a backend whose block exists and whose
driverequals the bound name → build or reuse that backend instance, shared with every other domain mapped to the same name. - Not mapped, and the bound name equals the global
[assignment] driver→ the global instance.
- Mapped to a backend whose block exists and whose
- Mapped to a missing or
driver-mismatched backend, or bound to a non-global name with no mapping → fall back to the global driver and log aWARN. This is the stale-binding case: an operator removed or renamed a backend block while a domain’s API-stored binding still namesopenfga. The write-time check of §3 stops the common case at the source; the resolve-time fallback keeps a bad edit from failing token issuance.
5. Untargeted requests fan out across every active driver and union
Four production paths reach the provider without a target:
crates/keystone/src/api/v3/auth/project/list.rs—GET /v3/auth/projectspassesuser_idandeffective: trueand discovers the projects; by definition it has no target.crates/keystone/src/api/v4/auth_plugin/identity_link/mod.rs(target_holds_system_role) —user_idandeffective: true, filtering the result for system assignments. This one is avoidable: it could setsystem_idand take the default driver directly, sincesystemis a singleton target, and the implementation should do so.crates/keystone/src/scim/group/delete.rs— lists a group’s assignments bygroup_idalone and revokes each one.crates/keystone/src/api/v3/role_assignment/list.rs—GET /v3/role_assignmentswith entirely caller-supplied filters, which may carry neither actor nor target.
These query every active driver instance (the global driver plus each distinct per-domain backend instance) and union the results.
Serving them from the default driver alone — the obvious cheap answer — is
rejected on two grounds. GET /v3/auth/projects would omit a user’s projects in
any OpenFGA-backed domain, breaking the unscoped→scoped token flow that
Horizon’s project picker depends on. Worse, SCIM group deletion would revoke
only the grants held in the default driver and silently leave the rest standing:
a group that looks deleted but still grants roles is exactly the escalation path
that code path exists to close.
Three sub-decisions follow.
The fan-out set. The active set is the global driver plus one instance per
distinct [assignment.backends.*] block that a bound domain maps to (§4). The
bindings come from the domain-config sources — the fs source holds them all in
memory from startup, the sql source needs a “list domains that set
assignment/driver” query on DomainConfigBackend — while the backend blocks
and the [assignment.domains] table are already in memory. Several domains
mapped to one backend contribute a single instance and a single fan-out query.
The set is warmed at startup, refreshed when a binding is written through the
config API, and rebuilt on a configuration reload (§9).
Merging. Results are concatenated, de-duplicated, then re-sorted and sliced
by Assignment::pagination_marker, so marker / limit / page_reverse apply
to the union rather than per driver. This is the same post-fetch pagination both
existing drivers already perform (ADR 0029, ADR 0033 §6), lifted one level up.
Partial failure fails the request. Any error from any driver in the fan-out
- including the
NotImplemented/ HTTP 501 shapes OpenFGA returns for unsupported listing shapes (ADR 0033 §10) - fails the whole call. The alternative, skipping the failing driver with a warning, silently shrinks a listing that a caller may be using to revoke grants. A short, honest 501 is better than a long answer that is quietly missing rows. The cost is stated in the Negative consequences.
6. The assignment group is cloud-admin only
Per doc/src/contributor/security-model.md, this is the load-bearing policy
requirement of the design.
Token issuance resolves a scope’s roles through the target’s driver:
resolve_domain_roles, resolve_project_default_roles, resolve_project_roles
and resolve_trust_roles in crates/core/src/auth.rs all call
list_role_assignments with a target and effective: true. A principal who can
write a domain’s assignment/driver moves that domain’s role resolution to a
different backend.
§3 and §4 already bound what that backend can be: sql, the global [openfga]
store, or a named [assignment.backends.*] block the operator mapped to the
domain — never an endpoint the principal supplies. The residual privilege is
still real: flipping the binding activates whatever backend the operator has
staged for that domain, or the global OpenFGA store, and changes which backend
resolves the domain’s roles. The domain configuration API policy must therefore
restrict the assignment group to cloud administrators, even where a domain
administrator may write that domain’s identity and ldap groups. The policy
rules must key on the authentication chain, as every other authorization
decision does, and must not be satisfiable by a domain-scoped token.
The system carve-out of §1 bounds the residual exposure: even a
misconfiguration here cannot manufacture system-scoped roles, so it cannot reach
the rest of the cloud.
7. No single-domain registration lock for assignment/sql
Identity needs one — DomainConfigService::claim_sql_registration lets a single
domain claim identity/driver = sql, because the SQL identity tables cannot
represent more than one domain’s users.
Assignment rows carry globally unique actor and target ids and are already
partitioned by target, so any number of domains can share the SQL assignment
driver without collision. The omission of a lock is therefore deliberate, and is
recorded here so that it is not later read as an oversight in the identity
parity. It also means an SQL-backed domain needs no [assignment.domains] entry
at all (§4).
8. Caching and invalidation
The resolved domain_id → driver-name binding is cached in an
RwLock<HashMap<..>>, mirroring IdentityService::resolved_driver_cache. The
backend parameters are not cached at this layer — they are read from
ConfigManager’s live configuration and refreshed on its reload path (§9) - and
backend instances are keyed by backend name, so N domains mapped to one backend
hold one entry. Whether the resolver has a source at all is likewise read from
live configuration: the [domain_config] gate of §2 (with its [identity]
fallback) is re-evaluated on the same reload path.
Unlike identity, the binding cache is not invalidation-free: the config API writer clears it in its own process, and a configuration reload rebuilds it (§9). What is not covered is a database-sourced binding write on another node, which that node observes only after its own reload signal or a restart. Because only the binding — a driver name — is database-sourced, that gap can move a domain between already-configured backends; it can never carry an endpoint or a secret across nodes.
The fan-out set of §5 must be refreshed on the same events, since it changes
whenever a domain gains or loses an assignment/driver binding or the
[assignment.domains] / [assignment.backends.*] configuration changes. The
config API writer invalidates the binding cache and the fan-out set in its own
process on every write; a configuration reload invalidates them too and
additionally rebuilds the resolver and the per-backend driver instances (§9).
9. Configuration reload rebuilds the resolver, drivers and caches
Keystone reloads its configuration in place, and provider subsystems react
through ConfigManager::notify_tx: the dummy-hash cache, the rate limiters and
the database connection each have a reactor in
crates/keystone/src/server/startup/background.rs. The per-domain assignment
state must react the same way, because every input it derives from can change
across a reload:
[assignment] domain_specific_drivers_enabled— dispatch may switch on or off — and the[domain_config]source keys of §2 (with their[identity]fallbacks) — the resolver may gain or lose a source.- the
[assignment.backends.*]blocks and the[assignment.domains]table — a backend’s parameters, a whole backend, or a domain’s mapping may be added, edited or removed. - the
fsdomain-config directory — a domain’s storedassignment/driverbinding file may be added, edited or removed on disk. - the global
[assignment]section — the driver every unconfigured domain and everysystemtarget inherits.
A new reload_assignment_drivers_on_config_change reactor, registered beside
the others in background.rs, subscribes to notify_tx and on each
notification (or a lagged receiver) performs, against the current configuration:
- Resolver. Rebuild
AssignmentService’sDomainConfigResolverfrom the current effective[domain_config]gate —Nonewhen the dispatch switch is now off, sourceless when both[domain_config]keys resolve off — so the file source re-reads the config directory. Identity’s own resolver instance is not touched (§2). - Binding cache. Drop the resolved
domain_id→ driver-name map of §8 in full. - Per-backend instances. Resolve the set of
(bound name, mapped backend block)pairs the current configuration produces and diff it by backend identity against the live instance map: build and swap in an instance for a backend that is new or whose parameters changed, drop an instance no bound domain maps to any more, and leave an unchanged backend’s instance — and its connection pool — untouched. Editing one[assignment.backends.*]block rebuilds exactly that instance; every domain mapped to it picks up the new instance on its next resolve. - Fan-out set. Recompute the §5 set from the rebuilt inputs.
This requires the resolver, the binding cache, the instance map and the fan-out
set to sit behind interior mutability on AssignmentService — one ArcSwap
over an immutable bundle is enough and keeps readers lock-free — where today
backend_driver is a plain Arc.
Failure is last-known-good, matching the rate-limiter reactor: a reload whose new configuration does not resolve is logged and the previous bundle is retained, rather than tearing running drivers down on a bad edit.
This closes the in-process half of the §8 invalidation gap for file-sourced
bindings and for every driver parameter, and goes one step beyond the identity
path, which still needs a restart to pick up a changed identity/driver.
Bringing the identity service onto the same reactor is reasonable follow-up work
but is out of scope here.
Consequences
Positive
- A domain can be migrated onto OpenFGA on its own, so ADR 0033’s driver can be exercised against real traffic without wagering every domain’s token issuance on the deployment having built a group-membership sync.
- Driver endpoints and the OpenFGA bearer token never cross the API boundary or
land in the shared
domain_configtable: the API caller selects among backends the operator has provisioned on the host, it cannot define one (§3). - Any number of domains share one OpenFGA backend — one client, one connection
pool, one fan-out query — by mapping to a named
[assignment.backends.*]block, with no driver configuration repeated per domain (§4). - The new domain-config surface is a single option,
assignment/driver. There is no per-[openfga]-option whitelist to keep in sync with the driver struct. - Target-keyed dispatch is closed under project-tree inheritance and domain-level inherited grants, so no driver ever has to answer a question about a target it does not own.
- Both write paths dispatch from data they already carry; no new field on
AssignmentCreateorAssignmentis needed. - System-scoped assignments are structurally excluded from domain-selected backends, so the most privileged scope keeps a single, operator-controlled source of truth.
- The resolution layer, the config API, the whitelist mechanism and the
sensitive-option redaction are reused unchanged; the new surface is one group
variant, one
resolve_*method, the two config maps and the dispatch. - No persistence change and no migration: an existing deployment with the switch off behaves exactly as it does today.
- The resolver’s source gating is a named
[domain_config]concern rather than a side effect of an[identity]switch (§2). Enabling per-domain assignment drivers takes[assignment] domain_specific_drivers_enabledplus a[domain_config]source; no[identity]key is involved, and existing deployments keep working through the[identity]fallback. - A configuration reload picks up a changed switch, a changed global
[assignment]section, and an added, edited or removed backend block, domain mapping or domain binding file, without a restart (§9) — a capability the identity path does not yet have.
Negative
- Onboarding a domain onto a named OpenFGA backend is a two-role operation.
The operator adds or reuses an
[assignment.backends.*]block, adds an[assignment.domains]mapping and reloads; a cloud administrator then writes theassignment/driverbinding through the API. A binding with no matching mapping is inert — it falls back to the global driver with aWARN(§4) — so the two steps must agree. This is the deliberate cost of keeping the configuration off the API. (Binding a domain to the global OpenFGA store needs no config edit — just the API write.) - Divergence from the identity per-domain model. Issue #1202 puts a domain’s identity driver configuration through the config API; this ADR keeps the assignment driver configuration in server config. Until identity follows, an operator meets two different mental models for two adjacent features. The security asymmetry — an assignment backend mints roles cloud-wide through token issuance — is the justification, recorded in §3.
- An untargeted listing is as weak as the weakest driver. With one
OpenFGA-backed domain, an unfiltered
GET /v3/role_assignments, a role-only listing, and any actor-only non-effective listing return 501 for the whole cloud, because the fan-out propagates the driver’s unsupported-shape error (§5). Notably the SCIM group-delete path (crates/keystone/src/scim/group/delete.rs) lists bygroup_idwitheffectiveunset, which is precisely that shape — SCIM group deletion breaks against an OpenFGA-backed domain until that call site setseffective, and this should be fixed as part of the implementation rather than left to be discovered in production. - Untargeted calls cost O(distinct active backends).
GET /v3/auth/projectsis on this path and is already the most expensive query on the OpenFGA driver (ADR 0033 lists it as ausers × target kinds × role relationsgraph-walk fan-out). Fanning it out multiplies that — once per distinct backend, not once per domain, since domains sharing a backend share its query. Worth profiling before enabling per-domain drivers on a cloud with many distinct OpenFGA backends. - Driver instances stop being singletons. Named
[assignment.backends.*]blocks mean an instance map keyed by backend name, lazily built, with its own lifecycle — a shared store is still one instance, but this is a departure from the one-instance-per-driver-name registry every other backend uses, and a new place for connection pools to accumulate. - A new privilege boundary to get right. §6 is a policy requirement, not
something the type system enforces. A policy file that lets a domain admin
write the
assignmentgroup binds that domain’s role resolution to another backend. This must be covered by an explicit policy test, not left to review. - Source gating and dispatch are separate knobs.
[domain_config]decides whether the resolver has a source;[assignment] domain_specific_drivers_enableddecides whether assignment dispatches per domain. Both are required, and setting only the second still resolves every domain to the global driver (§2). This is deliberate — the sources are shared with identity — but it is one more thing an operator must get right. - Two config keys only partially migrate.
[identity] domain_configurations_from_databasebecomes a deprecated alias;[identity] domain_specific_drivers_enabledkeeps its identity-dispatch job and is not deprecated, only its source-gating side effect moves;[identity] domain_config_dirstays put. The[identity]section keeps a foot in domain-config gating until the identity path is reworked too (§2). - Stale resolution is now more visible. With identity, a stale cached driver affects one domain’s users. Here, a stale fan-out set can omit an entire domain from an untargeted listing until restart.
- Cross-driver consistency is not transactional. A grant written to one domain’s driver and a revocation event written centrally are two operations; a driver failing mid-way leaves them disagreeing, as it does today, but with more drivers there are more ways for it to happen.
check_grantdiverges further. It already behaves differently between SQL and OpenFGA (ADR 0033). Per-domain drivers mean a single deployment can now exhibit both behaviours. Harmless while it has no production caller; a future caller must not assume uniformity.- A configuration reload now churns assignment state. Where a reload today
touches a cache and a connection pool, it now re-resolves every configured
backend and may build or drop
AssignmentBackendinstances, each with its own OpenFGA client and pool. A reload that flips thedomain_specific_drivers_enabledswitch off tears down every per-backend instance at once. The reactor must diff rather than rebuild wholesale (§9) so that an unrelated reload does not drop healthy driver connections.
Testing
The implementation should carry, at minimum:
- A
per_domain_dispatchunit suite forAssignmentService, modelled oncrates/core/src/identity/service/tests/per_domain_dispatch.rs: resolution from a storedassignment/driver, the empty-config and resolution-error fallbacks to the global driver, per-domain caching (the config source consulted exactly once for two calls), project→domain resolution for a project target, a domain target, and the system carve-out. - A cross-domain grant test proving the invariant of §1 directly: a grant by an actor in domain A on a project in domain B is written to and read from B’s driver, and is visible in B’s target-scoped listing.
- Fan-out tests for §5: the union and de-duplication, marker pagination applied
across the union rather than per driver, and a driver returning
NotImplementedfailing the whole call rather than being skipped. - A policy test asserting the
assignmentgroup is refused to a domain-scoped administrator (§6), since nothing else enforces it. - A binding-validation test: a config-API write of
assignment/drivernaming a driver that no[assignment.backends.*]block or the global[assignment]section defines (and notsql) is rejected, and a resolve against a binding whose mapped backend was later removed falls back to the global driver with aWARN(§4). - A shared-backend test: two domains mapped to one
[assignment.backends.*]block resolve to the same instance (pointer equality), editing that block rebuilds it once and both domains observe the new instance, and the fan-out set counts it once. - A reload-reactor test (§9): a reload that adds, changes and removes
[assignment.backends.*]blocks and[assignment.domains]mappings swaps the corresponding instances and leaves an unchanged backend’s instance identical (pointer equality); the binding cache and fan-out set are rebuilt; flipping the switch off drops every per-backend instance; a reload with an unresolvable new configuration retains the previous bundle. - An integration suite modelled on
tests/integration/src/domain_config.rs, driving two domains on two drivers through the provider stack, including a scoped-token issue against each. - Config-parsing coverage for the new switch, the
assignmentdomain-config group and the[assignment.backends.*]/[assignment.domains]maps inopenstack-keystone-configandopenstack-keystone-core-types. [domain_config]gating coverage (§2): aDomainConfigResolver::newtest that an explicit[domain_config]key overrides a conflicting[identity]one (and logs theWARN), that an unset key falls back to the[identity]value including its default, and that with every gate off the resolver holds no source and every domain resolves to the global driver; plus a config-parse test forOption<bool>round-tripping (unset vs explicitfalse).
Note that crates/core-types/src/domain_config/option.rs currently uses
"assignment" as its example of an unknown group in
unknown_group_is_rejected; that test needs a different example once this
lands.