Skip to content

feat: Add domain config SQL driver - #1139

Open
magdang wants to merge 1 commit into
openstack-experimental:mainfrom
magdang:feat/domain-config-sql-driver
Open

feat: Add domain config SQL driver#1139
magdang wants to merge 1 commit into
openstack-experimental:mainfrom
magdang:feat/domain-config-sql-driver

Conversation

@magdang

@magdang magdang commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of #954: the SQL persistence for the
/v3/domains/{domain_id}/config API family.

Rebased onto main now that #1138 (Phase 0, the core types and the
backend trait) has merged, so this is a single commit against main.

There is still no service or handler, so nothing resolves a backend of
the DomainConfigBackend kind yet — the driver is reachable only through
the registry it submits itself to (name: "sql"), and its tables through
keystone-manage db sync.

Storage

One row per option keyed by (domain_id, group, option), value in a text
column as the JSON it was written as, matching python-keystone's
JsonBlob — whatever type a client writes is the type a later read
returns.

The tables are named whitelisted_config and sensitive_config, i.e.
python-keystone's names, rather than the domain_config /
domain_config_sensitive sketched in the issue. Every other *-driver-sql
crate in this workspace transcribes python-keystone's schema down to the
table and column names (user, group, local_user, …), and Phase 0's
core types already document these two by name.

They are part of the initial schema python-keystone installs, so this
driver ships no migration of its own (per review feedback): the tables
are created from their entities on keystone-manage db sync, like every
other natively-compatible provider. A test against a real SQLite database
covers that path, which also pins down that the reserved words group and
option are usable as column names.

Sensitive options

Keeping secrets in their own table is what makes the read paths safe by
construction rather than by filtering:

  • the group and option scoped reads back the endpoints that hand options
    to a client, and they only ever select from whitelisted_config;
  • the option scoped read answers a sensitive option with None without
    issuing a statement at all;
  • only the whole-config read touches sensitive_config, because the
    identity backend has to be handed the bind password — and DomainConfig
    skips it on serialization, so it still cannot reach a response.

Substitution

%(option)s references are stored verbatim and resolved by
DomainConfig::substitute, not on a driver read. Resolving them inside
get_domain_config would inline the bind password into ldap.url, which
that same read serves to the API; the resolved form is for the identity
backend alone, so it belongs to the resolution layer, where the issue also
lists it (Phase 3).

Behaviour notes

  • Reads are tolerant of drift, as the core types are: a row that is no
    longer configurable is skipped with a warning rather than failing the
    whole read, which would otherwise leave the domain unreadable — and its
    identity backend uninitializable — until an operator deleted the row.
    Both spellings of that drift, a group that is no longer configurable and
    an option that is no longer whitelisted, are dropped as the row is
    decoded, which is the one place every read passes through. Dropping the
    latter deeper down, where DomainConfig::from_options does it, would let
    it reach the emptiness check the reads make their None decision on — so
    a domain holding nothing but a drifted row would answer 200 with an
    empty configuration where a drifted group answers 404 — and the
    option scoped read, which has no later from_options to drop it, would
    serve it outright.
  • A partial delete reports what is not there (404), as python-keystone's
    delete_config does; the row count answers that without a preceding
    read, and the transaction rolls back the half that did match. Deleting a
    whole configuration that is not there stays silent, also matching
    python-keystone's driver.
  • The option scoped write rejects an option that is neither whitelisted
    nor sensitive: storing one would create a row every later read skips.
  • Write paths return the merged state including sensitive options. They
    are what the caller just supplied and they are skipped on serialization,
    so echoing them back cannot put a secret on the wire, while a caller
    that patches only ldap.password still sees a group that is not empty.
    All of them answer with what was stored rather than what was asked for,
    the way python-keystone answers from the rows it wrote: the two differ
    over a group the whitelist emptied, which the request still carries and
    no later read would ever report.

Outside the driver crate

  • One additive error variant in core-types, GroupMismatch, for a group
    scoped write whose payload addresses another group (python-keystone's
    "trying to update group X, so that, and only that, …").
  • DomainConfigBackend added to PluginManager's backend registry so the
    driver's inventory::submit! has a registry to submit to. Resolving and
    holding the backend comes with the provider that consumes it — Phase 3/4.

Testing

  • cargo test -p openstack-keystone-domain-config-driver-sql — 50 tests,
    including two against a real SQLite database for the schema.
  • cargo test -p openstack-keystone-core-types -p openstack-keystone-core
  • keystone-manage db sync against a fresh SQLite database creates both
    tables with the composite primary key; db up still runs clean.
  • cargo clippy --all-targets on the touched crates: no new warnings.

Part of #954. Closes #956.

@magdang
magdang force-pushed the feat/domain-config-sql-driver branch 2 times, most recently from 4c0d363 to f7666b5 Compare August 3, 2026 10:39

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this migration is not necessary - it is part of the initial schema installed by the python keystone - no native compatible provider defines migration

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — dropped. The migration module, its migrations() override and the sea-orm-migration dependency are all gone; the two tables are now created only from their entities on keystone-manage db sync, like the other natively-compatible providers. Verified end to end against a fresh SQLite database:

$ keystone-manage --config keystone.conf db sync
schema sync completed successfully
$ sqlite3 db.sqlite ".schema whitelisted_config"
CREATE TABLE "whitelisted_config" ( "domain_id" varchar(64) NOT NULL, "group" varchar(255) NOT NULL, "option" varchar(255) NOT NULL, "value" text NOT NULL, CONSTRAINT "pk-whitelisted_config" PRIMARY KEY ("domain_id", "group", "option") );

The branch is also rebased onto main now that #1138 has merged, so it is a single commit and no longer conflicting.

@magdang
magdang force-pushed the feat/domain-config-sql-driver branch from f7666b5 to 0545044 Compare August 7, 2026 19:36
The SQL persistence behind the /v3/domains/{domain_id}/config API
family: one row per option keyed by (domain_id, group, option), the
value stored as the JSON it was written as, matching python-keystone's
JsonBlob.

The tables are python-keystone's whitelisted_config and
sensitive_config, created from their entities on `keystone-manage db
sync`. They are part of python-keystone's initial schema, so this
driver ships no migration of its own.

Keeping the secrets in their own table is what makes the read paths
safe by construction rather than by filtering: the group and option
scoped reads back the endpoints that hand options to a client, and
they only ever select from whitelisted_config. Only the whole-config
read touches sensitive_config, because the identity backend has to be
handed the bind password, and DomainConfig skips it on serialization.

Rows that drifted out of the configurable set are dropped when a row
is decoded, which is the one place every read passes through, so a
group that is no longer configurable and an option that is no longer
whitelisted read alike. Dropping the latter deeper down would let it
reach the emptiness check the reads make their None decision on, and
be served outright by the option scoped read, which has no later
DomainConfig::from_options to drop it.

%(option)s references are stored verbatim and resolved by
DomainConfig::substitute, never on a driver read: resolving them in
get_domain_config would inline the bind password into ldap.url, which
that same read serves to the API.

Nothing resolves a DomainConfigBackend yet; the driver is reachable
only through the registry it submits itself to (name: "sql").

Part of openstack-experimental#954. Closes openstack-experimental#956.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Yousef Hussein <ymh1874@gmail.com>
@magdang
magdang force-pushed the feat/domain-config-sql-driver branch from 0545044 to e30db97 Compare August 7, 2026 19:54
@magdang
magdang requested a review from gtema August 7, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1: Domain config SQL driver

2 participants